From b12aad4e73c4a67cbecb65205836e3eb4808fdb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Tue, 13 Jan 2026 15:29:52 +0800 Subject: [PATCH] feat: Enhance file management and user features - Add file deduplication mechanism based on PhysicalFile reference counting. - Implement chunked upload support for large files with resumable uploads. - Update sharing page to automatically render README and preview content. - Integrate Redis for caching and token storage (optional). - Refactor project structure to include new models for download tasks, nodes, and tasks. - Introduce user filtering parameters for admin user management. - Add CORS middleware for handling cross-origin requests. - Improve error messages for authentication failures. - Update user model to include two-factor authentication key management. - Enhance API documentation and response models for clarity. - Implement admin checks for user management and permissions. --- .xml | 4407 ------------------------- README.md | 70 +- ROADMAP.md | 105 +- main.py | 10 + middleware/auth.py | 2 +- middleware/dependencies.py | 24 + models/README.md | 202 +- models/migration.py | 2 +- models/user.py | 99 +- routers/api/v1/admin/__init__.py | 19 +- routers/api/v1/admin/user/__init__.py | 19 +- routers/api/v1/user/__init__.py | 6 +- utils/http/http_exceptions.py | 14 +- 13 files changed, 467 insertions(+), 4512 deletions(-) delete mode 100644 .xml diff --git a/.xml b/.xml deleted file mode 100644 index 81272a6..0000000 --- a/.xml +++ /dev/null @@ -1,4407 +0,0 @@ - - - - - - C:\Users\Administrator\Documents\Code\Server\middleware - C:\Users\Administrator\Documents\Code\Server\models - C:\Users\Administrator\Documents\Code\Server\routers - C:\Users\Administrator\Documents\Code\Server\service - C:\Users\Administrator\Documents\Code\Server\utils - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/README.md b/README.md index 02f85cc..b5017cc 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,12 @@ - **多存储策略**:支持本地存储、S3 兼容 API、阿里云 OSS、OneDrive 等多种存储后端 - **远程节点**:可对接从节点分担存储和下载任务 - **WebDAV 兼容**:提供标准 WebDAV 接口,支持第三方客户端访问 +- **文件去重**:基于 PhysicalFile 引用计数的文件去重机制 ### 文件管理 - **统一对象模型**:文件和目录采用统一的 Object 模型管理 +- **分片上传**:支持大文件分片上传,可断点续传 - **在线压缩/解压**:支持批量打包下载 - **离线下载**:内置离线下载服务,也可对接 Aria2/qBittorrent @@ -50,7 +52,7 @@ ### 分享功能 - **分享链接管理**:可设置密码、过期时间 -- **分享页展示** 自动渲染分享中的 README、在线预览内容 +- **分享页展示**:自动渲染分享中的 README、在线预览内容 ### 增值服务 @@ -67,8 +69,10 @@ | [Python 3.13+](https://www.python.org/) | 编程语言 | | [FastAPI](https://fastapi.tiangolo.com/) | 高性能异步 Web 框架 | | [SQLModel](https://sqlmodel.tiangolo.com/) | 类型安全的 ORM(SQLAlchemy + Pydantic) | +| [Redis](https://redis.io/) | 缓存与令牌存储(可选) | | [aiohttp](https://docs.aiohttp.org/) | 异步 HTTP 客户端 | | [aiosqlite](https://aiosqlite.omnilib.dev/) | 异步 SQLite 驱动 | +| [asyncpg](https://magicstack.github.io/asyncpg/) | 异步 PostgreSQL 驱动 | | [Loguru](https://loguru.readthedocs.io/) | 现代化日志库 | | [PyJWT](https://pyjwt.readthedocs.io/) | JWT 令牌处理 | | [WebAuthn](https://pypi.org/project/webauthn/) | Passkey 认证支持 | @@ -81,35 +85,59 @@ Server/ ├── main.py # 应用入口 ├── models/ # 数据模型 -│ ├── base/ # 基类定义 (SQLModelBase, TableBase) +│ ├── base/ # 基类定义 (SQLModelBase) +│ ├── mixin/ # Mixin 模块 (TableBaseMixin, UUIDTableBaseMixin) │ ├── user.py # 用户模型 +│ ├── user_authn.py # WebAuthn 凭证 │ ├── group.py # 用户组模型 -│ ├── object.py # 文件/目录统一模型 +│ ├── object.py # 文件/目录统一模型 + 上传会话 +│ ├── physical_file.py # 物理文件模型(文件去重) │ ├── policy.py # 存储策略模型 │ ├── share.py # 分享模型 +│ ├── download.py # 离线下载任务 +│ ├── node.py # 节点模型 +│ ├── task.py # 任务模型 │ └── ... ├── routers/ # API 路由 -│ └── api/v1/ # v1 版本 API -│ ├── user/ # 用户相关接口 -│ ├── directory/ # 目录相关接口 -│ ├── file/ # 文件相关接口 -│ ├── admin/ # 管理员接口 -│ └── ... +│ ├── api/v1/ # v1 版本 API +│ │ ├── user/ # 用户相关接口 +│ │ ├── directory/ # 目录相关接口 +│ │ ├── file/ # 文件上传/下载接口 +│ │ ├── object/ # 对象操作接口 +│ │ ├── share/ # 分享接口 +│ │ ├── admin/ # 管理员接口 +│ │ │ ├── user/ # 用户管理 +│ │ │ ├── group/ # 用户组管理 +│ │ │ ├── policy/ # 存储策略管理 +│ │ │ ├── file/ # 文件管理 +│ │ │ ├── share/ # 分享管理 +│ │ │ ├── task/ # 任务管理 +│ │ │ └── vas/ # 增值服务管理 +│ │ └── ... +│ └── dav/ # WebDAV 路由 ├── service/ # 业务服务层 -│ ├── user/ # 用户服务 -│ ├── captcha/ # 验证码服务 -│ └── oauth/ # OAuth 服务 +│ ├── user/ # 用户服务(登录) +│ ├── storage/ # 存储服务(本地存储) +│ ├── captcha/ # 验证码服务(reCAPTCHA、Turnstile) +│ ├── oauth/ # OAuth 服务(QQ、GitHub) +│ └── redis/ # Redis 服务(连接管理、令牌存储) ├── middleware/ # 中间件 │ ├── auth.py # 认证中间件 │ └── dependencies.py # 依赖注入 ├── utils/ # 工具函数 │ ├── JWT/ # JWT 处理 -│ ├── password/ # 密码处理 +│ ├── password/ # 密码处理(Argon2、TOTP) │ ├── conf/ # 配置管理 +│ ├── http/ # HTTP 异常处理 │ └── lifespan/ # 生命周期管理 └── tests/ # 测试用例 ├── unit/ # 单元测试 + │ ├── models/ # 模型测试 + │ ├── service/ # 服务测试 + │ └── utils/ # 工具测试 ├── integration/ # 集成测试 + │ ├── api/ # API 测试 + │ └── middleware/ # 中间件测试 └── fixtures/ # 测试夹具 ``` @@ -121,7 +149,7 @@ Server/ | 用户 | `/api/v1/user` | 用户注册、登录、设置 | | 目录 | `/api/v1/directory` | 目录浏览和管理 | | 文件 | `/api/v1/file` | 文件上传、下载、管理 | -| 对象 | `/api/v1/object` | 文件和目录的通用操作 | +| 对象 | `/api/v1/object` | 文件和目录的通用操作(删除、移动、复制、重命名) | | 分享 | `/api/v1/share` | 分享链接管理 | | 下载 | `/api/v1/download` | 离线下载管理 | | 标签 | `/api/v1/tag` | 用户标签管理 | @@ -137,6 +165,7 @@ Server/ - Python 3.13 或更高版本 - uv (推荐) 或 pip +- Redis (可选,用于令牌存储和缓存) ### 安装 @@ -157,8 +186,17 @@ uv sync # 调试模式 DEBUG=false +# 运行模式: master(主节点)或 slave(从节点) +MODE=master + # 数据库连接(默认使用 SQLite) DATABASE_URL=sqlite+aiosqlite:///disknext.db + +# Redis 配置(可选,不配置则使用内存缓存) +REDIS_URL=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 ``` ### 启动 @@ -171,7 +209,9 @@ fastapi dev fastapi run ``` -访问 查看 API 文档。 +访问 查看 API 文档(仅 DEBUG=true 时可用)。 + +首次启动会自动初始化数据库并创建默认管理员账户,**请注意控制台输出的初始密码**。 ## 测试 diff --git a/ROADMAP.md b/ROADMAP.md index 0814582..1d3e6ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,17 +24,25 @@ - [x] FastAPI 应用框架搭建 - [x] SQLModel ORM 集成 -- [x] 异步数据库支持 (aiosqlite) +- [x] 异步数据库支持 (aiosqlite, asyncpg) - [x] 项目结构规范化 -- [x] 开发规范文档 (CLAUDE.md) +- [x] 开发规范文档 (AGENTS.md) +- [x] Redis 集成(可选,支持降级) +- [x] 生命周期管理(启动/关闭事件) +- [x] 数据库自动迁移/初始化 #### 数据模型 -- [x] 基类定义 (SQLModelBase, TableBase, UUIDTableBase) +- [x] 基类定义 (SQLModelBase, TableBaseMixin, UUIDTableBaseMixin) +- [x] 自定义元类支持联表继承和多态 - [x] 用户模型 (User) +- [x] 用户 WebAuthn 凭证模型 (UserAuthn) - [x] 用户组模型 (Group, GroupOptions) - [x] 统一对象模型 (Object) - 合并文件和目录 -- [x] 存储策略模型 (Policy) +- [x] 物理文件模型 (PhysicalFile) - 文件去重 +- [x] 上传会话模型 (UploadSession) +- [x] 文件元数据模型 (FileMetadata) +- [x] 存储策略模型 (Policy, PolicyOptions) - [x] 分享模型 (Share) - [x] 标签模型 (Tag) - [x] WebDAV 模型 (WebDAV) @@ -48,21 +56,62 @@ - [x] JWT 令牌认证 - [x] 获取当前用户信息 - [x] 用户存储空间查询 +- [x] 用户设置获取 + +#### 目录系统 + +- [x] 目录浏览接口(路径解析) +- [x] 目录创建接口 + +#### 文件系统 + +- [x] 创建上传会话 +- [x] 分片上传接口 +- [x] 上传会话管理(删除、清除) +- [x] 创建空白文件 +- [x] 下载令牌生成 +- [x] 文件下载(一次性令牌) + +#### 对象操作 + +- [x] 对象删除(支持递归删除目录) +- [x] 对象移动 +- [x] 对象复制(引用计数,不复制物理文件) +- [x] 对象重命名 +- [x] 对象属性查询(基本属性、详细属性) #### 认证安全 - [x] Argon2 密码哈希 - [x] JWT 令牌生成与验证 - [x] 认证中间件 +- [x] 管理员权限中间件 - [x] 两步验证 (2FA/TOTP) 初始化与启用 - [x] WebAuthn 注册初始化 +- [x] 下载令牌验证(一次性使用) +- [x] 令牌存储(Redis/内存降级) + +#### 存储策略 + +- [x] 本地存储策略实现 +- [x] 存储路径生成(支持命名规则) +- [x] 文件写入/读取/删除 +- [x] 回收站机制 + +#### 管理后台 + +- [x] 管理员权限验证 +- [x] 站点概况统计(用户数、文件数、分享数趋势) +- [x] 设置管理(获取、批量更新) +- [x] Aria2 连接测试 #### 测试 - [x] pytest 测试框架配置 -- [x] 单元测试结构 -- [x] 集成测试结构 +- [x] 单元测试结构(models, service, utils) +- [x] 集成测试结构(api, middleware) - [x] 测试夹具 (fixtures) +- [x] 覆盖率报告配置 ### 进行中 @@ -73,15 +122,8 @@ - [ ] 用户设置管理 - [ ] 头像上传/Gravatar -#### 目录系统 - -- [ ] 目录浏览接口 -- [ ] 目录创建接口 -- [ ] 路径解析优化 - #### 存储策略 -- [ ] 本地存储策略实现 - [ ] S3 存储策略实现 --- @@ -90,21 +132,16 @@ ### 文件操作 -- [ ] 文件上传(单文件) -- [ ] 文件上传(分块上传) -- [ ] 文件下载 - [ ] 文件预览 URL 生成 - [ ] 缩略图生成 -- [ ] 文件移动/复制 -- [ ] 文件重命名 -- [ ] 文件删除(软删除/回收站) +- [ ] 文件内容获取(文本文件) +- [ ] Office 文档预览 +- [ ] 文件外链功能 ### 目录操作 - [ ] 目录树查询 -- [ ] 目录移动/复制 -- [ ] 目录删除(递归) -- [ ] 批量操作 +- [ ] 批量操作优化 ### 存储策略完善 @@ -117,10 +154,17 @@ ### 用户组权限 - [ ] 权限验证中间件 -- [ ] 存储空间限制 +- [ ] 存储空间限制检查 - [ ] 文件类型限制 - [ ] 单文件大小限制 +### 管理后台完善 + +- [ ] 用户管理接口 +- [ ] 用户组管理接口 +- [ ] 存储策略管理接口 +- [ ] 文件管理接口(封禁/解封) + ### Webhook 事件系统 - [ ] 文件事件推送(创建、修改、删除、分享) @@ -161,15 +205,12 @@ - [ ] 存储容量包 - [ ] 订单管理 -### 管理后台 +### 文件处理 -- [ ] 用户管理接口 -- [ ] 用户组管理接口 -- [ ] 存储策略管理接口 -- [ ] 系统设置接口 -- [ ] 任务管理接口 -- [ ] 文件管理接口 -- [ ] 数据统计接口 +- [ ] 文件压缩任务 +- [ ] 文件解压任务 +- [ ] 文件转移任务 +- [ ] 文件搜索功能 ### 协作功能 @@ -416,4 +457,4 @@ --- -*最后更新:2025年12月* +*最后更新:2026年1月* diff --git a/main.py b/main.py index f1a3335..a8bad8d 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ from typing import NoReturn from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware from utils.conf import appmeta from utils.http.http_exceptions import raise_internal_error @@ -33,6 +34,15 @@ app = FastAPI( openapi_url="/openapi.json" if appmeta.debug else None, ) +# 配置 CORS 中间件 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 开发环境允许所有来源,生产环境应该限制为具体域名 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + @app.exception_handler(Exception) async def handle_unexpected_exceptions(request: Request, exc: Exception) -> NoReturn: """ diff --git a/middleware/auth.py b/middleware/auth.py index b57a664..650d741 100644 --- a/middleware/auth.py +++ b/middleware/auth.py @@ -33,7 +33,7 @@ async def auth_required( return user except jwt.InvalidTokenError: - http_exceptions.raise_unauthorized("账号或密码错误") + http_exceptions.raise_unauthorized("凭据过期或无效") async def admin_required( user: Annotated[User, Depends(auth_required)], diff --git a/middleware/dependencies.py b/middleware/dependencies.py index 70cfebb..bbd1f50 100644 --- a/middleware/dependencies.py +++ b/middleware/dependencies.py @@ -5,15 +5,18 @@ FastAPI 依赖注入 - SessionDep: 数据库会话依赖 - TimeFilterRequestDep: 时间筛选查询依赖(用于 count 等统计接口) - TableViewRequestDep: 分页排序查询依赖(包含时间筛选 + 分页排序) +- UserFilterParamsDep: 用户筛选参数依赖(用于管理员用户列表) """ from datetime import datetime from typing import Annotated, Literal, TypeAlias +from uuid import UUID from fastapi import Depends, Query from sqlmodel.ext.asyncio.session import AsyncSession from models.database import get_session from models.mixin import TimeFilterRequest, TableViewRequest +from models.user import UserFilterParams, UserStatus # --- 数据库会话依赖 --- @@ -70,3 +73,24 @@ async def _get_table_view_queries( TableViewRequestDep: TypeAlias = Annotated[TableViewRequest, Depends(_get_table_view_queries)] """获取分页排序和时间筛选参数的依赖""" + + +# --- 用户筛选依赖 --- + +async def _get_user_filter_params( + group_id: Annotated[UUID | None, Query(description="按用户组UUID筛选")] = None, + username: Annotated[str | None, Query(max_length=50, description="按用户名模糊搜索")] = None, + nickname: Annotated[str | None, Query(max_length=50, description="按昵称模糊搜索")] = None, + status: Annotated[UserStatus | None, Query(description="按用户状态筛选")] = None, +) -> UserFilterParams: + """解析用户过滤查询参数""" + return UserFilterParams( + group_id=group_id, + username_contains=username, + nickname_contains=nickname, + status=status, + ) + + +UserFilterParamsDep: TypeAlias = Annotated[UserFilterParams, Depends(_get_user_filter_params)] +"""获取用户筛选参数的依赖(用于管理员用户列表)""" diff --git a/models/README.md b/models/README.md index 7ea0f77..6855961 100644 --- a/models/README.md +++ b/models/README.md @@ -8,10 +8,10 @@ models/ ├── base/ # 基础模型类 │ ├── __init__.py # 导出 SQLModelBase -│ └── sqlmodel_base.py # SQLModelBase 基类(元类魔法) +│ └── sqlmodel_base.py # SQLModelBase 基类(自定义元类,支持联表继承) ├── mixin/ # Mixin 模块 │ ├── __init__.py # 统一导出 -│ ├── table.py # TableBaseMixin, UUIDTableBaseMixin(CRUD + 时间戳) +│ ├── table.py # TableBaseMixin, UUIDTableBaseMixin(CRUD + 时间戳 + 分页) │ ├── polymorphic.py # 联表继承工具(create_subclass_id_mixin 等) │ └── info_response.py # DTO 用的 id/时间戳 Mixin ├── user.py # 用户模型 @@ -19,7 +19,7 @@ models/ ├── group.py # 用户组模型 ├── policy.py # 存储策略模型 ├── physical_file.py # 物理文件模型(文件去重) -├── object.py # 统一对象模型(文件/目录)+ 上传会话 +├── object.py # 统一对象模型(文件/目录)+ 上传会话 + 文件元数据 ├── share.py # 分享模型 ├── tag.py # 标签模型 ├── download.py # 离线下载任务 @@ -33,7 +33,8 @@ models/ ├── storage_pack.py # 容量包模型 ├── webdav.py # WebDAV 账户模型 ├── color.py # 主题颜色 DTO -├── model_base.py # 响应基类 DTO +├── model_base.py # 响应基类 DTO(ResponseBase, MCP 等) +├── migration.py # 数据库初始化和迁移 └── database.py # 数据库连接配置 ``` @@ -43,9 +44,13 @@ models/ ### SQLModelBase -所有模型的基类,位于 `models.base.sqlmodel_base`,配置了: +所有模型的基类,位于 `models.base.sqlmodel_base`,使用自定义元类 `__DeclarativeMeta` 实现: - `use_attribute_docstrings=True`:使用属性后的 docstring 作为字段描述 - `validate_by_name=True`:允许按名称验证 +- **自动设置 table=True**:继承 TableBaseMixin 的类自动成为数据库表 +- **联表继承支持**:自动检测并处理 Joined Table Inheritance +- **多态支持**:支持 `polymorphic_on`, `polymorphic_identity` 等参数 +- **Python 3.14 兼容**:包含针对 PEP 649 的兼容性修复 ### TableBaseMixin @@ -61,13 +66,18 @@ models/ 提供的 CRUD 方法: - `add()` - 新增记录(类方法) -- `save()` - 保存实例 -- `update()` - 更新记录 +- `save()` - 保存实例(**必须使用返回值**) +- `update()` - 更新记录(**必须使用返回值**) - `delete()` - 删除记录 -- `get()` - 查询记录(类方法) -- `get_with_count()` - 分页查询(类方法) +- `get()` - 查询记录(类方法,支持分页、排序、时间筛选、多态加载) +- `get_with_count()` - 分页查询并返回总数(类方法,返回 `ListResponse[T]`) - `get_exist_one()` - 获取存在的记录(不存在则抛出 404) -- `count()` - 统计记录数(类方法) +- `count()` - 统计记录数(类方法,支持时间筛选) + +分页排序请求类: +- `TimeFilterRequest` - 时间筛选参数 +- `PaginationRequest` - 分页排序参数 +- `TableViewRequest` - 组合分页排序和时间筛选 **使用方式**: ```python @@ -104,6 +114,23 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase password: str ``` +### ListResponse[T] + +泛型分页响应类,用于所有 LIST 端点的标准化响应格式: + +```python +class ListResponse(BaseModel, Generic[ItemT]): + count: int # 符合条件的记录总数 + items: list[T] # 当前页的记录列表 +``` + +**使用示例**: +```python +@router.get("", response_model=ListResponse[UserResponse]) +async def list_users(session: SessionDep, table_view: TableViewRequestDep): + return await User.get_with_count(session, table_view=table_view) +``` + --- ## 数据库表模型 @@ -118,10 +145,10 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase | `id` | `UUID` | 用户 UUID(主键) | | `username` | `str` | 用户名,唯一,不可更改 | | `nickname` | `str?` | 用户昵称 | -| `password` | `str` | 密码(加密后) | +| `password` | `str` | 密码(Argon2 加密) | | `status` | `UserStatus` | 用户状态:active/admin_banned/system_banned | | `storage` | `int` | 已用存储空间(字节) | -| `two_factor` | `str?` | 两步验证密钥 | +| `two_factor` | `str?` | 两步验证密钥(TOTP) | | `avatar` | `str` | 头像类型/地址 | | `score` | `int` | 用户积分 | | `group_expires` | `datetime?` | 当前用户组过期时间 | @@ -134,6 +161,8 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase **关系**: - `group`: 所属用户组 - `previous_group`: 之前的用户组(用于过期后恢复) +- `tags`: 用户的标签列表 +- `authns`: 用户的 WebAuthn 凭证列表 --- @@ -327,12 +356,17 @@ class User(UserBase, UUIDTableBaseMixin): # 不需要再写 SQLModelBase - `children`: 子对象列表(自引用) - `source_links`: 源链接列表 - `shares`: 分享列表 +- `policy`: 存储策略 **业务属性**: - `source_name`: 向后兼容属性,返回物理文件的存储路径 - `is_file`: 是否为文件 - `is_folder`: 是否为目录 +**类方法**: +- `get_by_path()`: 根据路径获取对象 +- `get_children()`: 获取子对象列表 + --- ### 10. FileMetadata(文件元数据) @@ -1026,8 +1060,8 @@ class OrderStatus(StrEnum): |-----|------| | `LoginRequest` | 登录请求 | | `RegisterRequest` | 注册请求 | -| `TokenResponse` | 访问令牌响应 | -| `UserResponse` | 用户信息响应 | +| `TokenResponse` | 访问令牌响应(access_token, refresh_token, expires_in) | +| `UserResponse` | 用户信息响应(包含 group) | | `UserPublic` | 用户公开信息 | | `UserSettingResponse` | 用户设置响应 | | `WebAuthnInfo` | WebAuthn 信息 | @@ -1042,37 +1076,44 @@ class OrderStatus(StrEnum): |-----|------| | `GroupBase` | 用户组基础字段 | | `GroupOptionsBase` | 用户组选项基础字段 | -| `GroupResponse` | 用户组响应 | +| `GroupAllOptionsBase` | 用户组所有选项基础字段 | +| `GroupResponse` | 用户组响应(包含 options) | +| `GroupCreateRequest` | 管理员创建用户组请求 | +| `GroupUpdateRequest` | 管理员更新用户组请求 | +| `GroupDetailResponse` | 管理员用户组详情响应 | +| `GroupListResponse` | 用户组列表响应 | ### 存储策略相关 | DTO | 说明 | |-----|------| +| `PolicyBase` | 存储策略基础字段 | | `PolicyOptionsBase` | 存储策略选项基础字段 | +| `PolicyResponse` | 存储策略响应(id, name, type, max_size) | +| `PolicySummary` | 存储策略摘要 | ### 对象相关 | DTO | 说明 | |-----|------| | `ObjectBase` | 对象基础字段 | -| `ObjectResponse` | 对象响应 | -| `DirectoryCreateRequest` | 创建目录请求 | -| `DirectoryResponse` | 目录响应 | -| `ObjectMoveRequest` | 移动对象请求 | -| `ObjectDeleteRequest` | 删除对象请求 | -| `ObjectCopyRequest` | 复制对象请求 | -| `ObjectRenameRequest` | 重命名对象请求 | +| `ObjectResponse` | 对象响应(目录列表中的单个项) | +| `DirectoryCreateRequest` | 创建目录请求(parent_id, name, policy_id?) | +| `DirectoryResponse` | 目录响应(id, parent, objects, policy) | +| `ObjectMoveRequest` | 移动对象请求(src_ids, dst_id) | +| `ObjectDeleteRequest` | 删除对象请求(ids) | +| `ObjectCopyRequest` | 复制对象请求(src_ids, dst_id) | +| `ObjectRenameRequest` | 重命名对象请求(id, new_name) | | `ObjectPropertyResponse` | 对象基本属性响应 | -| `ObjectPropertyDetailResponse` | 对象详细属性响应 | -| `PolicyResponse` | 存储策略响应 | +| `ObjectPropertyDetailResponse` | 对象详细属性响应(含元数据、分享统计) | ### 上传相关 | DTO | 说明 | |-----|------| -| `CreateUploadSessionRequest` | 创建上传会话请求 | -| `UploadSessionResponse` | 上传会话响应 | -| `UploadChunkResponse` | 上传分片响应 | +| `CreateUploadSessionRequest` | 创建上传会话请求(file_name, file_size, parent_id) | +| `UploadSessionResponse` | 上传会话响应(id, chunk_size, total_chunks) | +| `UploadChunkResponse` | 上传分片响应(uploaded_chunks, is_complete) | | `CreateFileRequest` | 创建空白文件请求 | ### 管理员文件管理 @@ -1083,15 +1124,52 @@ class OrderStatus(StrEnum): | `FileBanRequest` | 文件封禁请求 | | `AdminFileListResponse` | 管理员文件列表响应 | +### 管理员概况 + +| DTO | 说明 | +|-----|------| +| `MetricsSummary` | 统计摘要(日期列表、每日增量、总计) | +| `LicenseInfo` | 许可证信息 | +| `VersionInfo` | 版本信息 | +| `AdminSummaryData` | 管理员概况数据 | +| `AdminSummaryResponse` | 管理员概况响应 | + ### 系统设置 | DTO | 说明 | |-----|------| | `SiteConfigResponse` | 站点配置响应 | | `ThemeResponse` | 主题颜色响应 | -| `SettingItem` | 设置项 | -| `SettingsUpdateRequest` | 更新设置请求 | -| `SettingsGetResponse` | 获取设置响应 | +| `SettingItem` | 设置项(type, name, value) | +| `SettingsListResponse` | 设置列表响应 | +| `SettingsUpdateRequest` | 更新设置请求(settings[]) | +| `SettingsUpdateResponse` | 更新设置响应(updated, created) | + +### 分享相关 + +| DTO | 说明 | +|-----|------| +| `ShareBase` | 分享基础字段 | +| `ShareCreateRequest` | 创建分享请求 | +| `ShareResponse` | 分享响应 | +| `AdminShareListItem` | 管理员分享列表项 | + +### 任务相关 + +| DTO | 说明 | +|-----|------| +| `TaskPropsBase` | 任务属性基础字段 | +| `TaskSummary` | 任务摘要 | + +### 通用响应 + +| DTO | 说明 | +|-----|------| +| `ResponseBase` | 通用响应基类(code, msg, data) | +| `ListResponse[T]` | 泛型分页响应(count, items) | +| `MCPBase` | MCP 基类 | +| `MCPRequestBase` | MCP 请求基类 | +| `MCPResponseBase` | MCP 响应基类 | --- @@ -1115,6 +1193,13 @@ objects = await Object.get( (Object.owner_id == user_id) & (Object.type == ObjectType.FILE), fetch_mode="all" ) + +# 分页查询并返回总数 +from models.mixin import TableViewRequest, ListResponse + +table_view = TableViewRequest(offset=0, limit=20, desc=True, order="created_at") +result: ListResponse[User] = await User.get_with_count(session, table_view=table_view) +print(f"总数: {result.count}, 当前页: {len(result.items)}") ``` ### 创建文件对象 @@ -1126,15 +1211,64 @@ file = Object( size=1024, owner_id=user.id, parent_id=folder.id, - policy_id=policy.id + policy_id=policy.id, + physical_file_id=physical_file.id, ) -file = await file.save(session) +file = await file.save(session) # 必须使用返回值 ``` ### 多对多关系操作 ```python # 为用户组添加存储策略 -group.policies.append(policy) -await group.save(session) +from models import GroupPolicyLink + +link = GroupPolicyLink(group_id=group.id, policy_id=policy.id) +session.add(link) +await session.commit() +``` + +### 文件上传流程 + +```python +# 1. 创建上传会话 +upload_session = UploadSession( + file_name="large_file.zip", + file_size=104857600, # 100MB + chunk_size=52428800, # 50MB + total_chunks=2, + owner_id=user.id, + parent_id=folder.id, + policy_id=policy.id, +) +upload_session = await upload_session.save(session) + +# 2. 上传分片后更新进度 +upload_session.uploaded_chunks += 1 +upload_session.uploaded_size += chunk_size +upload_session = await upload_session.save(session) + +# 3. 检查是否完成 +if upload_session.is_complete: + # 创建 PhysicalFile 和 Object 记录 + ... +``` + +### 文件引用计数(去重) + +```python +# 复制文件时,只增加引用计数,不复制物理文件 +if src.is_file and src.physical_file_id: + physical_file = await PhysicalFile.get(session, PhysicalFile.id == src.physical_file_id) + physical_file.increment_reference() + await physical_file.save(session) + +# 删除文件时,减少引用计数 +physical_file.decrement_reference() +if physical_file.can_be_deleted: + # 引用计数为0,可以删除物理文件 + await storage_service.delete_file(physical_file.storage_path) + await PhysicalFile.delete(session, physical_file) +else: + await physical_file.save(session) ``` diff --git a/models/migration.py b/models/migration.py index bc747f5..c4061e8 100644 --- a/models/migration.py +++ b/models/migration.py @@ -25,7 +25,7 @@ default_settings: list[Setting] = [ Setting(name="siteURL", value="http://localhost", type=SettingsType.BASIC), Setting(name="siteName", value="DiskNext", type=SettingsType.BASIC), Setting(name="register_enabled", value="1", type=SettingsType.REGISTER), - Setting(name="default_group", value="", type=SettingsType.REGISTER), # UUID 在组创建后更新 + Setting(name="default_group", value="", type=SettingsType.REGISTER), Setting(name="siteKeywords", value="网盘,网盘", type=SettingsType.BASIC), Setting(name="siteDes", value="DiskNext", type=SettingsType.BASIC), Setting(name="siteTitle", value="云星启智", type=SettingsType.BASIC), diff --git a/models/user.py b/models/user.py index e9c13d8..287b502 100644 --- a/models/user.py +++ b/models/user.py @@ -1,14 +1,19 @@ from datetime import datetime from enum import StrEnum -from typing import Literal, TYPE_CHECKING +from typing import Literal, TYPE_CHECKING, TypeVar from uuid import UUID -from sqlmodel import Field, Relationship from pydantic import BaseModel +from sqlalchemy import BinaryExpression, ClauseElement, and_ +from sqlmodel import Field, Relationship +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.main import RelationshipInfo from .base import SQLModelBase from .model_base import ResponseBase -from .mixin import UUIDTableBaseMixin +from .mixin import UUIDTableBaseMixin, TableViewRequest, ListResponse + +T = TypeVar("T", bound="User") if TYPE_CHECKING: from .group import Group @@ -38,12 +43,33 @@ class ThemeType(StrEnum): class UserStatus(StrEnum): """用户状态枚举""" - + ACTIVE = "active" ADMIN_BANNED = "admin_banned" SYSTEM_BANNED = "system_banned" +# ==================== 筛选参数 ==================== + +class UserFilterParams(SQLModelBase): + """ + 用户过滤参数 + + 用于管理员搜索用户列表,支持用户组、用户名、昵称、状态等过滤。 + """ + group_id: UUID | None = None + """按用户组UUID筛选""" + + username_contains: str | None = Field(default=None, max_length=50) + """用户名包含(不区分大小写的模糊搜索)""" + + nickname_contains: str | None = Field(default=None, max_length=50) + """昵称包含(不区分大小写的模糊搜索)""" + + status: UserStatus | None = None + """按用户状态筛选""" + + # ==================== Base 模型 ==================== class UserBase(SQLModelBase): @@ -165,7 +191,7 @@ class UserPublic(UserBase): id: UUID | None = None """用户UUID""" - nick: str | None = None + nickname: str | None = None """昵称""" storage: int = 0 @@ -180,6 +206,9 @@ class UserPublic(UserBase): group_id: UUID | None = None """所属用户组UUID""" + two_factor: str | None = None + """两步验证密钥(32位字符串,null 表示未启用)""" + created_at: datetime | None = None """创建时间""" @@ -235,6 +264,9 @@ class UserAdminUpdateRequest(SQLModelBase): group_expires: datetime | None = None """用户组过期时间""" + two_factor: str | None = None + """两步验证密钥(32位字符串,传 null 可清除,不传则不修改)""" + class UserCalibrateResponse(SQLModelBase): """用户存储校准响应 DTO""" @@ -395,4 +427,59 @@ class User(UserBase, UUIDTableBaseMixin): def to_public(self) -> "UserPublic": """转换为公开 DTO,排除敏感字段""" - return UserPublic.model_validate(self) \ No newline at end of file + return UserPublic.model_validate(self) + + @classmethod + async def get_with_count( + cls: type[T], + session: AsyncSession, + condition: BinaryExpression | ClauseElement | None = None, + *, + filter_params: 'UserFilterParams | None' = None, + join: type[T] | tuple[type[T], ClauseElement] | None = None, + options: list | None = None, + load: RelationshipInfo | None = None, + order_by: list[ClauseElement] | None = None, + filter: BinaryExpression | ClauseElement | None = None, + table_view: TableViewRequest | None = None, + ) -> 'ListResponse[T]': + """ + 获取分页用户列表及总数,支持用户过滤参数 + + :param filter_params: UserFilterParams 过滤参数对象(用户组、用户名、昵称、状态等) + :param 其他参数: 继承自 UUIDTableBaseMixin.get_with_count() + """ + # 构建过滤条件 + merged_condition = condition + if filter_params is not None: + filter_conditions: list[BinaryExpression] = [] + + if filter_params.group_id is not None: + filter_conditions.append(cls.group_id == filter_params.group_id) + + if filter_params.username_contains is not None: + filter_conditions.append(cls.username.ilike(f"%{filter_params.username_contains}%")) + + if filter_params.nickname_contains is not None: + filter_conditions.append(cls.nickname.ilike(f"%{filter_params.nickname_contains}%")) + + if filter_params.status is not None: + filter_conditions.append(cls.status == filter_params.status) + + if filter_conditions: + combined_filter = and_(*filter_conditions) + if merged_condition is not None: + merged_condition = and_(merged_condition, combined_filter) + else: + merged_condition = combined_filter + + return await super().get_with_count( + session, + merged_condition, + join=join, + options=options, + load=load, + order_by=order_by, + filter=filter, + table_view=table_view, + ) \ No newline at end of file diff --git a/routers/api/v1/admin/__init__.py b/routers/api/v1/admin/__init__.py index 1ad8eee..52b9491 100644 --- a/routers/api/v1/admin/__init__.py +++ b/routers/api/v1/admin/__init__.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, status from loguru import logger as l from sqlalchemy import and_ @@ -56,6 +56,23 @@ admin_aria2_router = APIRouter( tags=['admin', 'admin_aria2'] ) +@admin_router.get( + path='/', + summary='自己是否为管理员', + dependencies=[Depends(admin_required)], + status_code=status.HTTP_204_NO_CONTENT +) +async def is_admin() -> None: + """ + 检查当前用户是否具有管理员权限。 + + 如果用户是管理员,则返回 204 No Content 响应;否则返回 403 Forbidden 错误。 + + Returns: + None: 无内容响应 + """ + return None + @admin_router.get( path='/summary', summary='获取站点概况', diff --git a/routers/api/v1/admin/user/__init__.py b/routers/api/v1/admin/user/__init__.py index 5357799..76a49b1 100644 --- a/routers/api/v1/admin/user/__init__.py +++ b/routers/api/v1/admin/user/__init__.py @@ -5,14 +5,14 @@ from loguru import logger as l from sqlalchemy import func, and_ from middleware.auth import admin_required -from middleware.dependencies import SessionDep, TableViewRequestDep +from middleware.dependencies import SessionDep, TableViewRequestDep, UserFilterParamsDep from models import ( User, ResponseBase, UserPublic, ListResponse, Group, Object, ObjectType, ) from models.user import ( UserAdminUpdateRequest, UserCalibrateResponse, ) -from utils import Password +from utils import Password, http_exceptions admin_user_router = APIRouter( prefix="/user", @@ -50,15 +50,17 @@ async def router_admin_get_user(session: SessionDep, user_id: int) -> ResponseBa async def router_admin_get_users( session: SessionDep, table_view: TableViewRequestDep, + filter_params: UserFilterParamsDep, ) -> ListResponse[UserPublic]: """ - 获取用户列表,支持分页、排序和时间筛选。 + 获取用户列表,支持分页、排序、时间筛选和用户筛选。 :param session: 数据库会话依赖项 :param table_view: 分页排序参数依赖 + :param filter_params: 用户筛选参数(用户组、用户名、昵称、状态) :return: 分页用户列表 """ - result = await User.get_with_count(session, table_view=table_view) + result = await User.get_with_count(session, filter_params=filter_params, table_view=table_view) return ListResponse( items=[user.to_public() for user in result.items], count=result.count, @@ -114,6 +116,10 @@ async def router_admin_update_user( if not user: raise HTTPException(status_code=404, detail="用户不存在") + # 默认管理员(用户名为 admin)不允许更改用户组 + if request.group_id and user.username == "admin" and request.group_id != user.group_id: + http_exceptions.raise_forbidden("默认管理员不允许更改用户组") + # 如果更新用户组,验证新组存在 if request.group_id: group = await Group.get(session, Group.id == request.group_id) @@ -127,6 +133,11 @@ async def router_admin_update_user( elif 'password' in update_data: del update_data['password'] # 空密码不更新 + # 验证两步验证密钥格式(如果提供了值且不为 None,长度必须为 32) + if 'two_factor' in update_data and update_data['two_factor'] is not None: + if len(update_data['two_factor']) != 32: + raise HTTPException(status_code=400, detail="两步验证密钥必须为32位字符串") + # 更新字段 for key, value in update_data.items(): setattr(user, key, value) diff --git a/routers/api/v1/user/__init__.py b/routers/api/v1/user/__init__.py index 5b07530..4228121 100644 --- a/routers/api/v1/user/__init__.py +++ b/routers/api/v1/user/__init__.py @@ -260,7 +260,7 @@ def router_user_avatar(id: str, size: int = 128) -> models.ResponseBase: summary='获取用户信息', description='Get user information.', dependencies=[Depends(dependency=auth_required)], - response_model=models.ResponseBase, + response_model=models.UserResponse, ) async def router_user_me( session: SessionDep, @@ -285,7 +285,7 @@ async def router_user_me( # 异步加载 tags 关系 user_tags = await user.awaitable_attrs.tags - user_response = models.UserResponse( + return models.UserResponse( id=user.id, username=user.username, status=user.status, @@ -297,8 +297,6 @@ async def router_user_me( tags=[tag.name for tag in user_tags] if user_tags else [], ) - return models.ResponseBase(data=user_response.model_dump()) - @user_router.get( path='/storage', summary='存储信息', diff --git a/utils/http/http_exceptions.py b/utils/http/http_exceptions.py index c8e488d..42ad969 100644 --- a/utils/http/http_exceptions.py +++ b/utils/http/http_exceptions.py @@ -16,21 +16,21 @@ def raise_bad_request(*args, **kwargs) -> NoReturn: """Raises an HTTP 400 Bad Request exception.""" raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, *args, **kwargs) -def raise_unauthorized(*args, **kwargs) -> NoReturn: +def raise_unauthorized(detail: str | None = None, *args, **kwargs) -> NoReturn: """Raises an HTTP 401 Unauthorized exception.""" - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, *args, **kwargs) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail, *args, **kwargs) -def raise_insufficient_quota(detail: str = "积分不足,请充值", *args, **kwargs) -> NoReturn: +def raise_insufficient_quota(detail: str | None = None, *args, **kwargs) -> NoReturn: """Raises an HTTP 402 Payment Required exception.""" raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=detail, *args, **kwargs) -def raise_forbidden(*args, **kwargs) -> NoReturn: +def raise_forbidden(detail: str | None = None, *args, **kwargs) -> NoReturn: """Raises an HTTP 403 Forbidden exception.""" - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, *args, **kwargs) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail, *args, **kwargs) -def raise_not_found(*args, **kwargs) -> NoReturn: +def raise_not_found(detail: str | None = None, *args, **kwargs) -> NoReturn: """Raises an HTTP 404 Not Found exception.""" - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, *args, **kwargs) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail, *args, **kwargs) def raise_conflict(*args, **kwargs) -> NoReturn: """Raises an HTTP 409 Conflict exception."""