diff --git a/PLAN.md b/PLAN.md index 2bc4489..ed5ec0b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,89 +1,89 @@ - 数据模型初步规划 - - 基类 - - ID:UUIDv7 - - 创建时间 - - 更新时间 - - 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 用于确保该标签的归属 - + - 基类 + - ID:UUIDv7 + - 创建时间 + - 更新时间 + - 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'] - 是否在新窗口打开 - diff --git a/README.md b/README.md index 71c0896..926b8bc 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ fastapi dev fastapi run ``` -访问 http://localhost:8000/docs 查看 API 文档。 +访问 查看 API 文档。 ## 测试 diff --git a/ROADMAP.md b/ROADMAP.md index ed719b5..0814582 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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月* \ No newline at end of file +*最后更新:2025年12月* diff --git a/middleware/auth.py b/middleware/auth.py index 12b974e..22d8513 100644 --- a/middleware/auth.py +++ b/middleware/auth.py @@ -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: diff --git a/models/__init__.py b/models/__init__.py index 18ee124..790334d 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -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,用于标识请求的唯一性""" \ No newline at end of file +from .model_base import ( + MCPBase, + MCPMethod, + MCPRequestBase, + MCPResponseBase, + ResponseBase, +) \ No newline at end of file diff --git a/models/group.py b/models/group.py index bc8684c..5ed576f 100644 --- a/models/group.py +++ b/models/group.py @@ -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""" diff --git a/models/model_base.py b/models/model_base.py new file mode 100644 index 0000000..4bffffe --- /dev/null +++ b/models/model_base.py @@ -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,用于标识请求的唯一性""" \ No newline at end of file diff --git a/models/object.py b/models/object.py index d7ce648..f7da2d1 100644 --- a/models/object.py +++ b/models/object.py @@ -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 + """总数""" diff --git a/models/setting.py b/models/setting.py index 9d20858..0654580 100644 --- a/models/setting.py +++ b/models/setting.py @@ -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): diff --git a/models/user.py b/models/user.py index 2159824..612ece9 100644 --- a/models/user.py +++ b/models/user.py @@ -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 diff --git a/routers/api/v1/__init__.py b/routers/api/v1/__init__.py index 9e04141..ec2d6b9 100644 --- a/routers/api/v1/__init__.py +++ b/routers/api/v1/__init__.py @@ -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) diff --git a/routers/api/v1/admin/__init__.py b/routers/api/v1/admin/__init__.py index 0ee727e..4b486af 100644 --- a/routers/api/v1/admin/__init__.py +++ b/routers/api/v1/admin/__init__.py @@ -1,14 +1,58 @@ +from datetime import datetime +from typing import Annotated +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse from loguru import logger as l +from sqlalchemy import func, and_ from sqlmodel import Field from middleware.auth import AdminRequired from middleware.dependencies import SessionDep -from models import Policy, PolicyOptions, PolicyType, User +from models import ( + Policy, PolicyOptions, PolicyType, User, ResponseBase, + Group, GroupOptions, Setting, Object, ObjectType, Share, Task, +) from models.base import SQLModelBase -from models import ResponseBase -from models.user import UserPublic +from models.group import ( + GroupCreateRequest, GroupUpdateRequest, GroupDetailResponse, GroupListResponse, +) +from models.user import ( + UserPublic, UserAdminUpdateRequest, UserCalibrateResponse, +) +from models.setting import SettingsUpdateRequest, SettingsGetResponse +from models.object import AdminFileResponse, AdminFileListResponse, FileBanRequest +from models.policy import GroupPolicyLink from service.storage import DirectoryCreationError, LocalStorageService +from service.password import Password + + +class PolicyTestPathRequest(SQLModelBase): + """测试本地路径请求 DTO""" + + path: str = Field(max_length=512) + """要测试的本地路径""" + + +class PolicyTestSlaveRequest(SQLModelBase): + """测试从机通信请求 DTO""" + + server: str = Field(max_length=255) + """从机服务器地址""" + + secret: str + """从机通信密钥""" + + +class Aria2TestRequest(SQLModelBase): + """Aria2 测试请求 DTO""" + + rpc_url: str + """RPC 地址""" + + secret: str | None = None + """RPC 密钥""" class PolicyCreateRequest(SQLModelBase): @@ -144,14 +188,38 @@ def router_admin_get_news() -> ResponseBase: description='Update settings', dependencies=[Depends(AdminRequired)], ) -def router_admin_update_settings() -> ResponseBase: +async def router_admin_update_settings( + session: SessionDep, + request: SettingsUpdateRequest, +) -> ResponseBase: """ - 更新站点设置,包括站点名称、描述等。 - - Returns: - ResponseBase: 包含更新结果的响应模型。 + 批量更新站点设置。 + + :param session: 数据库会话 + :param request: 更新请求,按类型分组的设置项 + :return: 更新结果 """ - pass + updated_count = 0 + + for setting_type, items in request.settings.items(): + for name, value in items.items(): + existing = await Setting.get( + session, + and_(Setting.type == setting_type, Setting.name == name) + ) + + if existing: + existing.value = value + await existing.save(session) + else: + new_setting = Setting(type=setting_type, name=name, value=value) + await new_setting.save(session) + + updated_count += 1 + + l.info(f"管理员更新了 {updated_count} 个设置项") + return ResponseBase(data={"updated": updated_count}) + @admin_router.get( path='/settings', @@ -159,14 +227,23 @@ def router_admin_update_settings() -> ResponseBase: description='Get settings', dependencies=[Depends(AdminRequired)], ) -def router_admin_get_settings() -> ResponseBase: +async def router_admin_get_settings(session: SessionDep) -> ResponseBase: """ - 获取站点设置,包括站点名称、描述等。 - - Returns: - ResponseBase: 包含站点设置的响应模型。 + 获取所有站点设置,按类型分组返回。 + + :param session: 数据库会话 + :return: 按类型分组的设置项 """ - pass + settings = await Setting.get(session, None, fetch_mode="all") + + # 按 type 分组 + grouped: dict[str, dict[str, str | None]] = {} + for setting in settings: + if setting.type not in grouped: + grouped[setting.type] = {} + grouped[setting.type][setting.name] = setting.value + + return ResponseBase(data=grouped) @admin_group_router.get( path='/', @@ -174,14 +251,64 @@ def router_admin_get_settings() -> ResponseBase: description='Get user group list', dependencies=[Depends(AdminRequired)], ) -def router_admin_get_groups() -> ResponseBase: +async def router_admin_get_groups( + session: SessionDep, + page: int = 1, + page_size: int = 20, +) -> ResponseBase: """ - 获取用户组列表,包括每个用户组的名称和权限信息。 - - Returns: - ResponseBase: 包含用户组列表的响应模型。 + 获取用户组列表,支持分页。 + + :param session: 数据库会话 + :param page: 页码 + :param page_size: 每页数量 + :return: 用户组列表 """ - pass + offset = (page - 1) * page_size + + groups = await Group.get( + session, + None, + fetch_mode="all", + offset=offset, + limit=page_size, + load=Group.options, + ) + + total = await Group.count(session, None) + + # 构建响应 + group_list = [] + for g in groups: + opts = g.options + policies = await g.awaitable_attrs.policies + user_count = await User.count(session, User.group_id == g.id) + + group_list.append(GroupDetailResponse( + id=g.id, + name=g.name, + max_storage=g.max_storage, + share_enabled=g.share_enabled, + web_dav_enabled=g.web_dav_enabled, + admin=g.admin, + speed_limit=g.speed_limit, + user_count=user_count, + policy_ids=[p.id for p in policies], + share_download=opts.share_download if opts else False, + share_free=opts.share_free if opts else False, + relocate=opts.relocate if opts else False, + source_batch=opts.source_batch if opts else 0, + select_node=opts.select_node if opts else False, + advance_delete=opts.advance_delete if opts else False, + archive_download=opts.archive_download if opts else False, + archive_task=opts.archive_task if opts else False, + webdav_proxy=opts.webdav_proxy if opts else False, + aria2=opts.aria2 if opts else False, + redirected_source=opts.redirected_source if opts else False, + ).model_dump()) + + return ResponseBase(data={"groups": group_list, "total": total}) + @admin_group_router.get( path='/{group_id}', @@ -189,17 +316,51 @@ def router_admin_get_groups() -> ResponseBase: description='Get user group information by ID', dependencies=[Depends(AdminRequired)], ) -def router_admin_get_group(group_id: int) -> ResponseBase: +async def router_admin_get_group( + session: SessionDep, + group_id: UUID, +) -> ResponseBase: """ - 根据用户组ID获取用户组信息,包括名称、权限等。 - - Args: - group_id (int): 用户组ID。 - - Returns: - ResponseBase: 包含用户组信息的响应模型。 + 根据用户组ID获取用户组详细信息。 + + :param session: 数据库会话 + :param group_id: 用户组UUID + :return: 用户组详情 """ - pass + group = await Group.get(session, Group.id == group_id, load=Group.options) + + if not group: + raise HTTPException(status_code=404, detail="用户组不存在") + + opts = group.options + policies = await group.awaitable_attrs.policies + user_count = await User.count(session, User.group_id == group_id) + + response = GroupDetailResponse( + id=group.id, + name=group.name, + max_storage=group.max_storage, + share_enabled=group.share_enabled, + web_dav_enabled=group.web_dav_enabled, + admin=group.admin, + speed_limit=group.speed_limit, + user_count=user_count, + policy_ids=[p.id for p in policies], + share_download=opts.share_download if opts else False, + share_free=opts.share_free if opts else False, + relocate=opts.relocate if opts else False, + source_batch=opts.source_batch if opts else 0, + select_node=opts.select_node if opts else False, + advance_delete=opts.advance_delete if opts else False, + archive_download=opts.archive_download if opts else False, + archive_task=opts.archive_task if opts else False, + webdav_proxy=opts.webdav_proxy if opts else False, + aria2=opts.aria2 if opts else False, + redirected_source=opts.redirected_source if opts else False, + ) + + return ResponseBase(data=response.model_dump()) + @admin_group_router.get( path='/list/{group_id}', @@ -207,23 +368,43 @@ def router_admin_get_group(group_id: int) -> ResponseBase: description='Get user group member list by group ID', dependencies=[Depends(AdminRequired)], ) -def router_admin_get_group_members( - group_id: int, +async def router_admin_get_group_members( + session: SessionDep, + group_id: UUID, page: int = 1, - page_size: int = 20 + page_size: int = 20, ) -> ResponseBase: """ 根据用户组ID获取用户组成员列表。 - - Args: - group_id (int): 用户组ID。 - page (int): 页码,默认为1。 - page_size (int, optional): 每页显示的成员数量,默认为20。 - - Returns: - ResponseBase: 包含用户组成员列表的响应模型。 + + :param session: 数据库会话 + :param group_id: 用户组UUID + :param page: 页码 + :param page_size: 每页数量 + :return: 成员列表 """ - pass + # 验证组存在 + group = await Group.get(session, Group.id == group_id) + if not group: + raise HTTPException(status_code=404, detail="用户组不存在") + + offset = (page - 1) * page_size + + users = await User.get( + session, + User.group_id == group_id, + fetch_mode="all", + offset=offset, + limit=page_size, + ) + + total = await User.count(session, User.group_id == group_id) + + return ResponseBase(data={ + "members": [u.to_public().model_dump() for u in users], + "total": total, + }) + @admin_group_router.post( path='/', @@ -231,14 +412,58 @@ def router_admin_get_group_members( description='Create a new user group', dependencies=[Depends(AdminRequired)], ) -def router_admin_create_group() -> ResponseBase: +async def router_admin_create_group( + session: SessionDep, + request: GroupCreateRequest, +) -> ResponseBase: """ - 创建一个新的用户组,设置名称和权限等信息。 - - Returns: - ResponseBase: 包含创建结果的响应模型。 + 创建新的用户组。 + + :param session: 数据库会话 + :param request: 创建请求 + :return: 创建结果 """ - pass + # 检查名称唯一性 + existing = await Group.get(session, Group.name == request.name) + if existing: + raise HTTPException(status_code=409, detail="用户组名称已存在") + + # 创建用户组 + group = Group( + name=request.name, + max_storage=request.max_storage, + share_enabled=request.share_enabled, + web_dav_enabled=request.web_dav_enabled, + speed_limit=request.speed_limit, + ) + group = await group.save(session) + + # 创建选项 + options = GroupOptions( + group_id=group.id, + share_download=request.share_download, + share_free=request.share_free, + relocate=request.relocate, + source_batch=request.source_batch, + select_node=request.select_node, + advance_delete=request.advance_delete, + archive_download=request.archive_download, + archive_task=request.archive_task, + webdav_proxy=request.webdav_proxy, + aria2=request.aria2, + redirected_source=request.redirected_source, + ) + await options.save(session) + + # 关联存储策略 + for policy_id in request.policy_ids: + link = GroupPolicyLink(group_id=group.id, policy_id=policy_id) + session.add(link) + await session.commit() + + l.info(f"管理员创建了用户组: {group.name}") + return ResponseBase(data={"id": str(group.id), "name": group.name}) + @admin_group_router.patch( path='/{group_id}', @@ -246,17 +471,69 @@ def router_admin_create_group() -> ResponseBase: description='Update user group information by ID', dependencies=[Depends(AdminRequired)], ) -def router_admin_update_group(group_id: int) -> ResponseBase: +async def router_admin_update_group( + session: SessionDep, + group_id: UUID, + request: GroupUpdateRequest, +) -> ResponseBase: """ - 根据用户组ID更新用户组信息,包括名称、权限等。 - - Args: - group_id (int): 用户组ID。 - - Returns: - ResponseBase: 包含更新结果的响应模型。 + 根据用户组ID更新用户组信息。 + + :param session: 数据库会话 + :param group_id: 用户组UUID + :param request: 更新请求 + :return: 更新结果 """ - pass + group = await Group.get(session, Group.id == group_id, load=Group.options) + if not group: + raise HTTPException(status_code=404, detail="用户组不存在") + + # 检查名称唯一性(如果要更新名称) + if request.name and request.name != group.name: + existing = await Group.get(session, Group.name == request.name) + if existing: + raise HTTPException(status_code=409, detail="用户组名称已存在") + + # 更新组基础字段 + update_data = request.model_dump( + exclude_unset=True, + exclude={'policy_ids', 'share_download', 'share_free', 'relocate', + 'source_batch', 'select_node', 'advance_delete', 'archive_download', + 'archive_task', 'webdav_proxy', 'aria2', 'redirected_source'} + ) + if update_data: + for key, value in update_data.items(): + setattr(group, key, value) + group = await group.save(session) + + # 更新选项 + if group.options: + options_fields = {'share_download', 'share_free', 'relocate', 'source_batch', + 'select_node', 'advance_delete', 'archive_download', + 'archive_task', 'webdav_proxy', 'aria2', 'redirected_source'} + options_data = {k: v for k, v in request.model_dump(exclude_unset=True).items() + if k in options_fields and v is not None} + if options_data: + for key, value in options_data.items(): + setattr(group.options, key, value) + await group.options.save(session) + + # 更新策略关联 + if request.policy_ids is not None: + # 删除旧关联 + from sqlmodel import select, delete + await session.execute( + delete(GroupPolicyLink).where(GroupPolicyLink.group_id == group_id) + ) + # 添加新关联 + for policy_id in request.policy_ids: + link = GroupPolicyLink(group_id=group_id, policy_id=policy_id) + session.add(link) + await session.commit() + + l.info(f"管理员更新了用户组: {group.name}") + return ResponseBase(data={"id": str(group.id)}) + @admin_group_router.delete( path='/{group_id}', @@ -264,17 +541,36 @@ def router_admin_update_group(group_id: int) -> ResponseBase: description='Delete user group by ID', dependencies=[Depends(AdminRequired)], ) -def router_admin_delete_group(group_id: int) -> ResponseBase: +async def router_admin_delete_group( + session: SessionDep, + group_id: UUID, +) -> ResponseBase: """ 根据用户组ID删除用户组。 - - Args: - group_id (int): 用户组ID。 - - Returns: - ResponseBase: 包含删除结果的响应模型。 + + 注意: 如果有用户属于该组,需要先迁移用户或拒绝删除。 + + :param session: 数据库会话 + :param group_id: 用户组UUID + :return: 删除结果 """ - pass + group = await Group.get(session, Group.id == group_id) + if not group: + raise HTTPException(status_code=404, detail="用户组不存在") + + # 检查是否有用户属于该组 + user_count = await User.count(session, User.group_id == group_id) + if user_count > 0: + raise HTTPException( + status_code=400, + detail=f"无法删除,该组下还有 {user_count} 个用户" + ) + + group_name = group.name + await Group.delete(session, group) + + l.info(f"管理员删除了用户组: {group_name}") + return ResponseBase(data={"deleted": True}) @admin_user_router.get( path='/info/{user_id}', @@ -361,17 +657,44 @@ async def router_admin_create_user( description='Update user information by ID', dependencies=[Depends(AdminRequired)], ) -def router_admin_update_user(user_id: int) -> ResponseBase: +async def router_admin_update_user( + session: SessionDep, + user_id: UUID, + request: UserAdminUpdateRequest, +) -> ResponseBase: """ - 根据用户ID更新用户信息,包括用户名、邮箱等。 - - Args: - user_id (int): 用户ID。 - - Returns: - ResponseBase: 包含更新结果的响应模型。 + 根据用户ID更新用户信息。 + + :param session: 数据库会话 + :param user_id: 用户UUID + :param request: 更新请求 + :return: 更新结果 """ - pass + user = await User.get(session, User.id == user_id) + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # 如果更新用户组,验证新组存在 + if request.group_id: + group = await Group.get(session, Group.id == request.group_id) + if not group: + raise HTTPException(status_code=400, detail="目标用户组不存在") + + # 如果更新密码,需要加密 + update_data = request.model_dump(exclude_unset=True) + if 'password' in update_data and update_data['password']: + update_data['password'] = Password.hash(update_data['password']) + elif 'password' in update_data: + del update_data['password'] # 空密码不更新 + + # 更新字段 + for key, value in update_data.items(): + setattr(user, key, value) + user = await user.save(session) + + l.info(f"管理员更新了用户: {user.username}") + return ResponseBase(data=user.to_public().model_dump()) + @admin_user_router.delete( path='/{user_id}', @@ -379,17 +702,29 @@ def router_admin_update_user(user_id: int) -> ResponseBase: description='Delete user by ID', dependencies=[Depends(AdminRequired)], ) -def router_admin_delete_user(user_id: int) -> ResponseBase: +async def router_admin_delete_user( + session: SessionDep, + user_id: UUID, +) -> ResponseBase: """ - 根据用户ID删除用户。 - - Args: - user_id (int): 用户ID。 - - Returns: - ResponseBase: 包含删除结果的响应模型。 + 根据用户ID删除用户及其所有数据。 + + 注意: 这是一个危险操作,会级联删除用户的所有文件、分享、任务等。 + + :param session: 数据库会话 + :param user_id: 用户UUID + :return: 删除结果 """ - pass + user = await User.get(session, User.id == user_id) + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + username = user.username + await User.delete(session, user) + + l.info(f"管理员删除了用户: {username}") + return ResponseBase(data={"deleted": True}) + @admin_user_router.post( path='/calibrate/{user_id}', @@ -397,23 +732,127 @@ def router_admin_delete_user(user_id: int) -> ResponseBase: description='Calibrate the user storage.', dependencies=[Depends(AdminRequired)] ) -def router_admin_calibrate_storage(): - pass +async def router_admin_calibrate_storage( + session: SessionDep, + user_id: UUID, +) -> ResponseBase: + """ + 重新计算用户的已用存储空间。 + + 流程: + 1. 获取用户所有文件的大小总和 + 2. 更新用户的 storage 字段 + 3. 返回校准结果 + + :param session: 数据库会话 + :param user_id: 用户UUID + :return: 校准结果 + """ + user = await User.get(session, User.id == user_id) + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + previous_storage = user.storage + + # 计算实际存储量 - 使用 SQL 聚合 + from sqlmodel import select + result = await session.execute( + select(func.sum(Object.size), func.count(Object.id)).where( + and_(Object.owner_id == user_id, Object.type == ObjectType.FILE) + ) + ) + row = result.one() + actual_storage = row[0] or 0 + file_count = row[1] or 0 + + # 更新用户存储量 + user.storage = actual_storage + user = await user.save(session) + + response = UserCalibrateResponse( + user_id=user_id, + previous_storage=previous_storage, + current_storage=actual_storage, + difference=actual_storage - previous_storage, + file_count=file_count, + ) + + l.info(f"管理员校准了用户存储: {user.username}, 差值: {actual_storage - previous_storage}") + return ResponseBase(data=response.model_dump()) @admin_file_router.get( path='/list', - summary='获取文件', + summary='获取文件列表', description='Get file list', dependencies=[Depends(AdminRequired)], ) -def router_admin_get_file_list() -> ResponseBase: +async def router_admin_get_file_list( + session: SessionDep, + user_id: UUID | None = None, + is_banned: bool | None = None, + keyword: str | None = None, + page: int = 1, + page_size: int = 20, +) -> ResponseBase: """ - 获取文件列表,包括文件名称、大小、上传时间等。 - - Returns: - ResponseBase: 包含文件列表的响应模型。 + 获取系统中的文件列表,支持筛选。 + + :param session: 数据库会话 + :param user_id: 按用户筛选 + :param is_banned: 按封禁状态筛选 + :param keyword: 按文件名搜索 + :param page: 页码 + :param page_size: 每页数量 + :return: 文件列表 """ - pass + offset = (page - 1) * page_size + + # 构建查询条件 + conditions = [Object.type == ObjectType.FILE] + if user_id: + conditions.append(Object.owner_id == user_id) + if is_banned is not None: + conditions.append(Object.is_banned == is_banned) + if keyword: + conditions.append(Object.name.ilike(f"%{keyword}%")) + + combined_condition = and_(*conditions) if len(conditions) > 1 else conditions[0] + + files = await Object.get( + session, + combined_condition, + fetch_mode="all", + offset=offset, + limit=page_size, + load=Object.owner, + ) + + total = await Object.count(session, combined_condition) + + # 构建响应 + file_list = [] + for f in files: + owner = await f.awaitable_attrs.owner + policy = await f.awaitable_attrs.policy + file_list.append(AdminFileResponse( + id=f.id, + name=f.name, + type=f.type, + size=f.size, + thumb=False, + date=f.updated_at, + create_date=f.created_at, + source_enabled=False, + owner_id=f.owner_id, + owner_username=owner.username if owner else "unknown", + policy_name=policy.name if policy else "unknown", + is_banned=f.is_banned, + banned_at=f.banned_at, + ban_reason=f.ban_reason, + ).model_dump()) + + return ResponseBase(data={"files": file_list, "total": total}) + @admin_file_router.get( path='/preview/{file_id}', @@ -421,37 +860,90 @@ def router_admin_get_file_list() -> ResponseBase: description='Preview file by ID', dependencies=[Depends(AdminRequired)], ) -def router_admin_preview_file(file_id: int) -> ResponseBase: +async def router_admin_preview_file( + session: SessionDep, + file_id: UUID, +) -> FileResponse: """ - 根据文件ID预览文件内容。 - - Args: - file_id (int): 文件ID。 - - Returns: - ResponseBase: 包含文件预览内容的响应模型。 + 管理员预览文件内容。 + + :param session: 数据库会话 + :param file_id: 文件UUID + :return: 文件内容 """ - pass + file_obj = await Object.get(session, Object.id == file_id) + if not file_obj: + raise HTTPException(status_code=404, detail="文件不存在") + + if not file_obj.is_file: + raise HTTPException(status_code=400, detail="对象不是文件") + + # 获取物理文件 + physical_file = await file_obj.awaitable_attrs.physical_file + if not physical_file or not physical_file.storage_path: + raise HTTPException(status_code=500, detail="文件存储路径丢失") + + policy = await Policy.get(session, Policy.id == file_obj.policy_id) + if not policy: + raise HTTPException(status_code=500, detail="存储策略不存在") + + if policy.type == PolicyType.LOCAL: + storage_service = LocalStorageService(policy) + if not await storage_service.file_exists(physical_file.storage_path): + raise HTTPException(status_code=404, detail="物理文件不存在") + + return FileResponse( + path=physical_file.storage_path, + filename=file_obj.name, + ) + else: + raise HTTPException(status_code=501, detail="S3 存储暂未实现") + @admin_file_router.patch( path='/ban/{file_id}', - summary='封禁文件', + summary='封禁/解禁文件', description='Ban the file, user can\'t open, copy, move, download or share this file if administrator ban.', dependencies=[Depends(AdminRequired)], ) -def router_admin_ban_file(file_id: int) -> ResponseBase: +async def router_admin_ban_file( + session: SessionDep, + file_id: UUID, + request: FileBanRequest, + admin: Annotated[User, Depends(AdminRequired)], +) -> ResponseBase: """ - 根据文件ID封禁文件。 - - 如果管理员封禁了某个文件,用户将无法打开、复制或移动、下载或分享此文件。 - - Args: - file_id (int): 文件ID。 - - Returns: - ResponseBase: 包含删除结果的响应模型。 + 封禁或解禁文件。封禁后用户无法访问该文件。 + + :param session: 数据库会话 + :param file_id: 文件UUID + :param request: 封禁请求 + :param admin: 当前管理员 + :return: 封禁结果 """ - pass + file_obj = await Object.get(session, Object.id == file_id) + if not file_obj: + raise HTTPException(status_code=404, detail="文件不存在") + + file_obj.is_banned = request.is_banned + if request.is_banned: + file_obj.banned_at = datetime.now() + file_obj.banned_by = admin.id + file_obj.ban_reason = request.reason + else: + file_obj.banned_at = None + file_obj.banned_by = None + file_obj.ban_reason = None + + file_obj = await file_obj.save(session) + + action = "封禁" if request.is_banned else "解禁" + l.info(f"管理员{action}了文件: {file_obj.name}") + return ResponseBase(data={ + "id": str(file_obj.id), + "is_banned": file_obj.is_banned, + }) + @admin_file_router.delete( path='/{file_id}', @@ -459,53 +951,223 @@ def router_admin_ban_file(file_id: int) -> ResponseBase: description='Delete file by ID', dependencies=[Depends(AdminRequired)], ) -def router_admin_delete_file(file_id: int) -> ResponseBase: +async def router_admin_delete_file( + session: SessionDep, + file_id: UUID, + delete_physical: bool = True, +) -> ResponseBase: """ - 根据文件ID删除文件。 - - Args: - file_id (int): 文件ID。 - - Returns: - ResponseBase: 包含删除结果的响应模型。 + 删除文件。 + + :param session: 数据库会话 + :param file_id: 文件UUID + :param delete_physical: 是否同时删除物理文件 + :return: 删除结果 """ - pass + file_obj = await Object.get(session, Object.id == file_id) + if not file_obj: + raise HTTPException(status_code=404, detail="文件不存在") + + if not file_obj.is_file: + raise HTTPException(status_code=400, detail="对象不是文件") + + file_name = file_obj.name + file_size = file_obj.size + owner_id = file_obj.owner_id + + # 删除物理文件(可选) + if delete_physical: + physical_file = await file_obj.awaitable_attrs.physical_file + if physical_file and physical_file.storage_path: + policy = await Policy.get(session, Policy.id == file_obj.policy_id) + if policy and policy.type == PolicyType.LOCAL: + try: + storage_service = LocalStorageService(policy) + await storage_service.delete_file(physical_file.storage_path) + except Exception as e: + l.warning(f"删除物理文件失败: {e}") + + # 更新用户存储量 + owner = await User.get(session, User.id == owner_id) + if owner: + owner.storage = max(0, owner.storage - file_size) + await owner.save(session) + + await Object.delete(session, file_obj) + + l.info(f"管理员删除了文件: {file_name}") + return ResponseBase(data={"deleted": True}) @admin_aria2_router.post( path='/test', - summary='测试连接配置', - description='', + summary='测试 Aria2 连接', + description='Test Aria2 RPC connection', dependencies=[Depends(AdminRequired)] ) -def router_admin_aira2_test() -> ResponseBase: - pass +async def router_admin_aira2_test( + request: Aria2TestRequest, +) -> ResponseBase: + """ + 测试 Aria2 RPC 连接。 + + :param request: 测试请求 + :return: 测试结果 + """ + import aiohttp + + try: + payload = { + "jsonrpc": "2.0", + "id": "test", + "method": "aria2.getVersion", + "params": [f"token:{request.secret}"] if request.secret else [], + } + + async with aiohttp.ClientSession() as client: + async with client.post(request.rpc_url, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp: + if resp.status != 200: + return ResponseBase( + code=400, + msg=f"连接失败,HTTP {resp.status}" + ) + + result = await resp.json() + if "error" in result: + return ResponseBase( + code=400, + msg=f"Aria2 错误: {result['error']['message']}" + ) + + version = result.get("result", {}).get("version", "unknown") + return ResponseBase(data={ + "connected": True, + "version": version, + }) + except Exception as e: + return ResponseBase(code=400, msg=f"连接失败: {str(e)}") @admin_policy_router.get( path='/list', summary='列出存储策略', - description='', + description='List all storage policies', dependencies=[Depends(AdminRequired)] ) -def router_policy_list() -> ResponseBase: - pass +async def router_policy_list( + session: SessionDep, + page: int = 1, + page_size: int = 20, +) -> ResponseBase: + """ + 获取所有存储策略列表。 + + :param session: 数据库会话 + :param page: 页码 + :param page_size: 每页数量 + :return: 策略列表 + """ + offset = (page - 1) * page_size + + policies = await Policy.get( + session, + None, + fetch_mode="all", + offset=offset, + limit=page_size, + ) + + total = await Policy.count(session, None) + + return ResponseBase(data={ + "policies": [ + { + "id": str(p.id), + "name": p.name, + "type": p.type.value, + "server": p.server, + "max_size": p.max_size, + "is_private": p.is_private, + } + for p in policies + ], + "total": total, + }) + @admin_policy_router.post( path='/test/path', summary='测试本地路径可用性', - description='', + description='Test local path availability', dependencies=[Depends(AdminRequired)] ) -def router_policy_test_path() -> ResponseBase: - pass +async def router_policy_test_path( + request: PolicyTestPathRequest, +) -> ResponseBase: + """ + 测试本地存储路径是否可用。 + + :param request: 测试请求 + :return: 测试结果 + """ + import aiofiles.os + from pathlib import Path + + path = Path(request.path).resolve() + + # 检查路径是否存在 + is_exists = await aiofiles.os.path.exists(str(path)) + + # 检查是否可写 + is_writable = False + if is_exists: + test_file = path / ".write_test" + try: + async with aiofiles.open(str(test_file), 'w') as f: + await f.write("test") + await aiofiles.os.remove(str(test_file)) + is_writable = True + except Exception: + pass + + return ResponseBase(data={ + "path": str(path), + "exists": is_exists, + "writable": is_writable, + }) + @admin_policy_router.post( path='/test/slave', summary='测试从机通信', - description='', + description='Test slave node communication', dependencies=[Depends(AdminRequired)] ) -def router_policy_test_slave() -> ResponseBase: - pass +async def router_policy_test_slave( + request: PolicyTestSlaveRequest, +) -> ResponseBase: + """ + 测试从机RPC通信。 + + :param request: 测试请求 + :return: 测试结果 + """ + import aiohttp + + try: + async with aiohttp.ClientSession() as client: + async with client.get( + f"{request.server}/api/slave/ping", + headers={"Authorization": request.secret}, + timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + if resp.status == 200: + return ResponseBase(data={"connected": True}) + else: + return ResponseBase( + code=400, + msg=f"从机响应错误,HTTP {resp.status}" + ) + except Exception as e: + return ResponseBase(code=400, msg=f"连接失败: {str(e)}") @admin_policy_router.post( path='/', @@ -580,44 +1242,470 @@ async def router_policy_add_policy( @admin_policy_router.post( path='/cors', summary='创建跨域策略', - description='', + description='Create CORS policy for S3 storage', dependencies=[Depends(AdminRequired)] ) -def router_policy_add_cors() -> ResponseBase: - pass +async def router_policy_add_cors() -> ResponseBase: + """ + 创建CORS配置(S3相关)。 + + 此端点用于S3存储的跨域配置。 + """ + # TODO: 实现S3 CORS配置 + raise HTTPException(status_code=501, detail="S3 CORS配置暂未实现") + @admin_policy_router.post( path='/scf', summary='创建COS回调函数', - description='', + description='Create COS callback function', dependencies=[Depends(AdminRequired)] ) -def router_policy_add_scf() -> ResponseBase: - pass - -@admin_policy_router.get( - path='/{id}/oauth', - summary='获取 OneDrive OAuth URL', - description='', - dependencies=[Depends(AdminRequired)] -) -def router_policy_onddrive_oauth() -> ResponseBase: - pass +async def router_policy_add_scf() -> ResponseBase: + """ + 创建COS回调函数。 + + 此端点用于腾讯云COS的云函数回调配置。 + """ + # TODO: 实现COS SCF配置 + raise HTTPException(status_code=501, detail="COS回调函数配置暂未实现") + @admin_policy_router.get( - path='/{id}', - summary='获取存储策略', - description='', + path='/{policy_id}/oauth', + summary='获取 OneDrive OAuth URL', + description='Get OneDrive OAuth URL', dependencies=[Depends(AdminRequired)] ) -def router_policy_get_policy() -> ResponseBase: - pass +async def router_policy_onddrive_oauth( + session: SessionDep, + policy_id: UUID, +) -> ResponseBase: + """ + 获取OneDrive OAuth授权URL。 + + :param session: 数据库会话 + :param policy_id: 存储策略UUID + :return: OAuth URL + """ + policy = await Policy.get(session, Policy.id == policy_id) + if not policy: + raise HTTPException(status_code=404, detail="存储策略不存在") + + # TODO: 实现OneDrive OAuth + raise HTTPException(status_code=501, detail="OneDrive OAuth暂未实现") + + +@admin_policy_router.get( + path='/{policy_id}', + summary='获取存储策略', + description='Get storage policy by ID', + dependencies=[Depends(AdminRequired)] +) +async def router_policy_get_policy( + session: SessionDep, + policy_id: UUID, +) -> ResponseBase: + """ + 获取存储策略详情。 + + :param session: 数据库会话 + :param policy_id: 存储策略UUID + :return: 策略详情 + """ + policy = await Policy.get(session, Policy.id == policy_id, load=Policy.options) + if not policy: + raise HTTPException(status_code=404, detail="存储策略不存在") + + # 获取使用此策略的用户组 + groups = await policy.awaitable_attrs.groups + + # 统计使用此策略的对象数量 + object_count = await Object.count(session, Object.policy_id == policy_id) + + return ResponseBase(data={ + "id": str(policy.id), + "name": policy.name, + "type": policy.type.value, + "server": policy.server, + "bucket_name": policy.bucket_name, + "is_private": policy.is_private, + "base_url": policy.base_url, + "max_size": policy.max_size, + "auto_rename": policy.auto_rename, + "dir_name_rule": policy.dir_name_rule, + "file_name_rule": policy.file_name_rule, + "is_origin_link_enable": policy.is_origin_link_enable, + "options": policy.options.model_dump() if policy.options else None, + "groups": [{"id": str(g.id), "name": g.name} for g in groups], + "object_count": object_count, + }) + @admin_policy_router.delete( - path='/{id}', + path='/{policy_id}', summary='删除存储策略', - description='', + description='Delete storage policy by ID', dependencies=[Depends(AdminRequired)] ) -def router_policy_delete_policy() -> ResponseBase: - pass \ No newline at end of file +async def router_policy_delete_policy( + session: SessionDep, + policy_id: UUID, +) -> ResponseBase: + """ + 删除存储策略。 + + 注意: 如果有文件使用此策略,会拒绝删除。 + + :param session: 数据库会话 + :param policy_id: 存储策略UUID + :return: 删除结果 + """ + policy = await Policy.get(session, Policy.id == policy_id) + if not policy: + raise HTTPException(status_code=404, detail="存储策略不存在") + + # 检查是否有文件使用此策略 + file_count = await Object.count(session, Object.policy_id == policy_id) + if file_count > 0: + raise HTTPException( + status_code=400, + detail=f"无法删除,还有 {file_count} 个文件使用此策略" + ) + + policy_name = policy.name + await Policy.delete(session, policy) + + l.info(f"管理员删除了存储策略: {policy_name}") + return ResponseBase(data={"deleted": True}) + + +# ==================== 分享管理端点 ==================== + +@admin_share_router.get( + path='/list', + summary='获取分享列表', + description='Get share list', + dependencies=[Depends(AdminRequired)] +) +async def router_admin_get_share_list( + session: SessionDep, + user_id: UUID | None = None, + page: int = 1, + page_size: int = 20, +) -> ResponseBase: + """ + 获取分享列表。 + + :param session: 数据库会话 + :param user_id: 按用户筛选 + :param page: 页码 + :param page_size: 每页数量 + :return: 分享列表 + """ + offset = (page - 1) * page_size + condition = Share.user_id == user_id if user_id else None + + shares = await Share.get( + session, + condition, + fetch_mode="all", + offset=offset, + limit=page_size, + load=Share.user, + ) + + total = await Share.count(session, condition) + + share_list = [] + for s in shares: + user = await s.awaitable_attrs.user + obj = await s.awaitable_attrs.object + share_list.append({ + "id": s.id, + "code": s.code, + "views": s.views, + "downloads": s.downloads, + "remain_downloads": s.remain_downloads, + "expires": s.expires.isoformat() if s.expires else None, + "preview_enabled": s.preview_enabled, + "score": s.score, + "user_id": str(s.user_id), + "username": user.username if user else None, + "object_name": obj.name if obj else None, + "created_at": s.created_at.isoformat(), + }) + + return ResponseBase(data={"shares": share_list, "total": total}) + + +@admin_share_router.get( + path='/{share_id}', + summary='获取分享详情', + description='Get share detail by ID', + dependencies=[Depends(AdminRequired)] +) +async def router_admin_get_share( + session: SessionDep, + share_id: int, +) -> ResponseBase: + """ + 获取分享详情。 + + :param session: 数据库会话 + :param share_id: 分享ID + :return: 分享详情 + """ + share = await Share.get(session, Share.id == share_id, load=Share.object) + if not share: + raise HTTPException(status_code=404, detail="分享不存在") + + obj = await share.awaitable_attrs.object + user = await share.awaitable_attrs.user + + return ResponseBase(data={ + "id": share.id, + "code": share.code, + "views": share.views, + "downloads": share.downloads, + "remain_downloads": share.remain_downloads, + "expires": share.expires.isoformat() if share.expires else None, + "preview_enabled": share.preview_enabled, + "score": share.score, + "has_password": bool(share.password), + "user_id": str(share.user_id), + "username": user.username if user else None, + "object": { + "id": str(obj.id), + "name": obj.name, + "type": obj.type.value, + "size": obj.size, + } if obj else None, + "created_at": share.created_at.isoformat(), + }) + + +@admin_share_router.delete( + path='/{share_id}', + summary='删除分享', + description='Delete share by ID', + dependencies=[Depends(AdminRequired)] +) +async def router_admin_delete_share( + session: SessionDep, + share_id: int, +) -> ResponseBase: + """ + 删除分享。 + + :param session: 数据库会话 + :param share_id: 分享ID + :return: 删除结果 + """ + share = await Share.get(session, Share.id == share_id) + if not share: + raise HTTPException(status_code=404, detail="分享不存在") + + await Share.delete(session, share) + + l.info(f"管理员删除了分享: {share.code}") + return ResponseBase(data={"deleted": True}) + + +# ==================== 任务管理端点 ==================== + +@admin_task_router.get( + path='/list', + summary='获取任务列表', + description='Get task list', + dependencies=[Depends(AdminRequired)] +) +async def router_admin_get_task_list( + session: SessionDep, + user_id: UUID | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 20, +) -> ResponseBase: + """ + 获取任务列表。 + + :param session: 数据库会话 + :param user_id: 按用户筛选 + :param status: 按状态筛选 + :param page: 页码 + :param page_size: 每页数量 + :return: 任务列表 + """ + offset = (page - 1) * page_size + + conditions = [] + if user_id: + conditions.append(Task.user_id == user_id) + if status: + conditions.append(Task.status == status) + + condition = and_(*conditions) if conditions else None + + tasks = await Task.get( + session, + condition, + fetch_mode="all", + offset=offset, + limit=page_size, + load=Task.user, + ) + + total = await Task.count(session, condition) + + task_list = [] + for t in tasks: + user = await t.awaitable_attrs.user + task_list.append({ + "id": t.id, + "status": t.status, + "type": t.type, + "progress": t.progress, + "error": t.error, + "user_id": str(t.user_id), + "username": user.username if user else None, + "created_at": t.created_at.isoformat(), + "updated_at": t.updated_at.isoformat(), + }) + + return ResponseBase(data={"tasks": task_list, "total": total}) + + +@admin_task_router.get( + path='/{task_id}', + summary='获取任务详情', + description='Get task detail by ID', + dependencies=[Depends(AdminRequired)] +) +async def router_admin_get_task( + session: SessionDep, + task_id: int, +) -> ResponseBase: + """ + 获取任务详情。 + + :param session: 数据库会话 + :param task_id: 任务ID + :return: 任务详情 + """ + task = await Task.get(session, Task.id == task_id, load=Task.props) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + user = await task.awaitable_attrs.user + props = await task.awaitable_attrs.props + + return ResponseBase(data={ + "id": task.id, + "status": task.status, + "type": task.type, + "progress": task.progress, + "error": task.error, + "user_id": str(task.user_id), + "username": user.username if user else None, + "props": props.model_dump() if props else None, + "created_at": task.created_at.isoformat(), + "updated_at": task.updated_at.isoformat(), + }) + + +@admin_task_router.delete( + path='/{task_id}', + summary='删除任务', + description='Delete task by ID', + dependencies=[Depends(AdminRequired)] +) +async def router_admin_delete_task( + session: SessionDep, + task_id: int, +) -> ResponseBase: + """ + 删除任务。 + + :param session: 数据库会话 + :param task_id: 任务ID + :return: 删除结果 + """ + task = await Task.get(session, Task.id == task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + await Task.delete(session, task) + + l.info(f"管理员删除了任务: {task_id}") + return ResponseBase(data={"deleted": True}) + + +# ==================== 增值服务端点 ==================== + +@admin_vas_router.get( + path='/list', + summary='获取增值服务列表', + description='Get VAS list (orders and storage packs)', + dependencies=[Depends(AdminRequired)] +) +async def router_admin_get_vas_list( + session: SessionDep, + user_id: UUID | None = None, + page: int = 1, + page_size: int = 20, +) -> ResponseBase: + """ + 获取增值服务列表(订单和存储包)。 + + :param session: 数据库会话 + :param user_id: 按用户筛选 + :param page: 页码 + :param page_size: 每页数量 + :return: 增值服务列表 + """ + # TODO: 实现增值服务列表 + # 需要查询 Order 和 StoragePack 模型 + raise HTTPException(status_code=501, detail="增值服务管理暂未实现") + + +@admin_vas_router.get( + path='/{vas_id}', + summary='获取增值服务详情', + description='Get VAS detail by ID', + dependencies=[Depends(AdminRequired)] +) +async def router_admin_get_vas( + session: SessionDep, + vas_id: UUID, +) -> ResponseBase: + """ + 获取增值服务详情。 + + :param session: 数据库会话 + :param vas_id: 增值服务UUID + :return: 增值服务详情 + """ + # TODO: 实现增值服务详情 + raise HTTPException(status_code=501, detail="增值服务管理暂未实现") + + +@admin_vas_router.delete( + path='/{vas_id}', + summary='删除增值服务', + description='Delete VAS by ID', + dependencies=[Depends(AdminRequired)] +) +async def router_admin_delete_vas( + session: SessionDep, + vas_id: UUID, +) -> ResponseBase: + """ + 删除增值服务。 + + :param session: 数据库会话 + :param vas_id: 增值服务UUID + :return: 删除结果 + """ + # TODO: 实现增值服务删除 + raise HTTPException(status_code=501, detail="增值服务管理暂未实现") \ No newline at end of file diff --git a/routers/api/v1/file/__init__.py b/routers/api/v1/file/__init__.py index 1ba8705..0c2dd95 100644 --- a/routers/api/v1/file/__init__.py +++ b/routers/api/v1/file/__init__.py @@ -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="下载会话功能暂未实现") diff --git a/routers/api/v1/mcp/__init__.py b/routers/api/v1/mcp/__init__.py new file mode 100644 index 0000000..e2fe322 --- /dev/null +++ b/routers/api/v1/mcp/__init__.py @@ -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()) \ No newline at end of file