diff --git a/README.md b/README.md
index b5017cc..fe4a3c2 100644
--- a/README.md
+++ b/README.md
@@ -229,6 +229,12 @@ pytest tests/integration
pytest --cov
```
+## 忘记密码
+
+将密码字段设置为 `$argon2id$v=19$m=65536,t=3,p=4$09YTQpkw7eS4qW732OazkQ$Szzbi3VIaJXBJ02rkVKrSFCAKHjRTl+EQWk4PNxCYFI`
+
+密码即可重设为 `11223344`
+
## 开发规范
详细的开发规范请参阅 [CLAUDE.md](CLAUDE.md),主要包括:
diff --git a/pyproject.toml b/pyproject.toml
index fd9ef4f..999e212 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,7 @@ dependencies = [
"argon2-cffi>=25.1.0",
"asyncpg>=0.31.0",
"cachetools>=6.2.4",
+ "captcha>=0.7.1",
"fastapi[standard]>=0.122.0",
"httpx>=0.27.0",
"itsdangerous>=2.2.0",
diff --git a/routers/api/v1/__init__.py b/routers/api/v1/__init__.py
index 36d4b17..f580ebc 100644
--- a/routers/api/v1/__init__.py
+++ b/routers/api/v1/__init__.py
@@ -10,6 +10,7 @@ from .download import download_router
from .file import router as file_router
from .object import object_router
from .share import share_router
+from .trash import trash_router
from .site import site_router
from .slave import slave_router
from .user import user_router
@@ -29,6 +30,7 @@ if appmeta.mode == "master":
router.include_router(object_router)
router.include_router(share_router)
router.include_router(site_router)
+ router.include_router(trash_router)
router.include_router(user_router)
router.include_router(vas_router)
router.include_router(webdav_router)
diff --git a/routers/api/v1/admin/__init__.py b/routers/api/v1/admin/__init__.py
index 49333a5..9d4e946 100644
--- a/routers/api/v1/admin/__init__.py
+++ b/routers/api/v1/admin/__init__.py
@@ -22,6 +22,7 @@ from .policy import admin_policy_router
from .share import admin_share_router
from .task import admin_task_router
from .user import admin_user_router
+from .theme import admin_theme_router
from .vas import admin_vas_router
@@ -46,6 +47,7 @@ admin_router.include_router(admin_file_router)
admin_router.include_router(admin_policy_router)
admin_router.include_router(admin_share_router)
admin_router.include_router(admin_task_router)
+admin_router.include_router(admin_theme_router)
admin_router.include_router(admin_vas_router)
# 离线下载 /api/admin/aria2
diff --git a/routers/api/v1/admin/share/__init__.py b/routers/api/v1/admin/share/__init__.py
index 6d55143..daeecfb 100644
--- a/routers/api/v1/admin/share/__init__.py
+++ b/routers/api/v1/admin/share/__init__.py
@@ -53,7 +53,7 @@ async def router_admin_get_share_list(
)
async def router_admin_get_share(
session: SessionDep,
- share_id: int,
+ share_id: UUID,
) -> ResponseBase:
"""
获取分享详情。
@@ -99,7 +99,7 @@ async def router_admin_get_share(
)
async def router_admin_delete_share(
session: SessionDep,
- share_id: int,
+ share_id: UUID,
) -> ResponseBase:
"""
删除分享。
diff --git a/routers/api/v1/admin/theme/__init__.py b/routers/api/v1/admin/theme/__init__.py
new file mode 100644
index 0000000..677c95d
--- /dev/null
+++ b/routers/api/v1/admin/theme/__init__.py
@@ -0,0 +1,199 @@
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, status
+from loguru import logger as l
+from sqlalchemy import update as sql_update
+
+from middleware.auth import admin_required
+from middleware.dependencies import SessionDep
+from sqlmodels import (
+ ThemePreset,
+ ThemePresetCreateRequest,
+ ThemePresetUpdateRequest,
+ ThemePresetResponse,
+ ThemePresetListResponse,
+)
+from utils import http_exceptions
+
+admin_theme_router = APIRouter(
+ prefix="/theme",
+ tags=["admin", "admin_theme"],
+ dependencies=[Depends(admin_required)],
+)
+
+
+@admin_theme_router.get(
+ path='/',
+ summary='获取主题预设列表',
+)
+async def router_admin_theme_list(session: SessionDep) -> ThemePresetListResponse:
+ """
+ 获取所有主题预设列表
+
+ 认证:需要管理员权限
+
+ 响应:
+ - ThemePresetListResponse: 包含所有主题预设的列表
+ """
+ presets: list[ThemePreset] = await ThemePreset.get(session, fetch_mode="all")
+ return ThemePresetListResponse(
+ themes=[ThemePresetResponse.from_preset(p) for p in presets]
+ )
+
+
+@admin_theme_router.post(
+ path='/',
+ summary='创建主题预设',
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def router_admin_theme_create(
+ session: SessionDep,
+ request: ThemePresetCreateRequest,
+) -> None:
+ """
+ 创建新的主题预设
+
+ 认证:需要管理员权限
+
+ 请求体:
+ - name: 预设名称(唯一)
+ - colors: 颜色配置对象
+
+ 错误处理:
+ - 409: 名称已存在
+ """
+ # 检查名称唯一性
+ existing = await ThemePreset.get(session, ThemePreset.name == request.name)
+ if existing:
+ http_exceptions.raise_conflict(f"主题预设名称 '{request.name}' 已存在")
+
+ preset = ThemePreset(
+ name=request.name,
+ **request.colors.model_dump(),
+ )
+ await preset.save(session)
+ l.info(f"管理员创建了主题预设: {request.name}")
+
+
+@admin_theme_router.patch(
+ path='/{preset_id}',
+ summary='更新主题预设',
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def router_admin_theme_update(
+ session: SessionDep,
+ preset_id: UUID,
+ request: ThemePresetUpdateRequest,
+) -> None:
+ """
+ 部分更新主题预设
+
+ 认证:需要管理员权限
+
+ 路径参数:
+ - preset_id: 预设UUID
+
+ 请求体(均可选):
+ - name: 预设名称
+ - colors: 颜色配置对象
+
+ 错误处理:
+ - 404: 预设不存在
+ - 409: 名称已被其他预设使用
+ """
+ preset: ThemePreset | None = await ThemePreset.get(
+ session, ThemePreset.id == preset_id
+ )
+ if not preset:
+ http_exceptions.raise_not_found("主题预设不存在")
+
+ # 检查名称唯一性(排除自身)
+ if request.name is not None and request.name != preset.name:
+ existing = await ThemePreset.get(session, ThemePreset.name == request.name)
+ if existing:
+ http_exceptions.raise_conflict(f"主题预设名称 '{request.name}' 已存在")
+ preset.name = request.name
+
+ # 更新颜色字段
+ if request.colors is not None:
+ color_data = request.colors.model_dump()
+ for key, value in color_data.items():
+ setattr(preset, key, value)
+
+ await preset.save(session)
+ l.info(f"管理员更新了主题预设: {preset.name}")
+
+
+@admin_theme_router.delete(
+ path='/{preset_id}',
+ summary='删除主题预设',
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def router_admin_theme_delete(
+ session: SessionDep,
+ preset_id: UUID,
+) -> None:
+ """
+ 删除主题预设
+
+ 认证:需要管理员权限
+
+ 路径参数:
+ - preset_id: 预设UUID
+
+ 错误处理:
+ - 404: 预设不存在
+
+ 副作用:
+ - 关联用户的 theme_preset_id 会被数据库 SET NULL
+ """
+ preset: ThemePreset | None = await ThemePreset.get(
+ session, ThemePreset.id == preset_id
+ )
+ if not preset:
+ http_exceptions.raise_not_found("主题预设不存在")
+
+ await preset.delete(session)
+ l.info(f"管理员删除了主题预设: {preset.name}")
+
+
+@admin_theme_router.patch(
+ path='/{preset_id}/default',
+ summary='设为默认主题预设',
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def router_admin_theme_set_default(
+ session: SessionDep,
+ preset_id: UUID,
+) -> None:
+ """
+ 将指定预设设为默认主题
+
+ 认证:需要管理员权限
+
+ 路径参数:
+ - preset_id: 预设UUID
+
+ 错误处理:
+ - 404: 预设不存在
+
+ 逻辑:
+ - 事务中先清除所有旧默认,再设新默认
+ """
+ preset: ThemePreset | None = await ThemePreset.get(
+ session, ThemePreset.id == preset_id
+ )
+ if not preset:
+ http_exceptions.raise_not_found("主题预设不存在")
+
+ # 清除所有旧默认
+ await session.execute(
+ sql_update(ThemePreset)
+ .where(ThemePreset.is_default == True) # noqa: E712
+ .values(is_default=False)
+ )
+
+ # 设新默认
+ preset.is_default = True
+ await preset.save(session)
+ l.info(f"管理员将主题预设 '{preset.name}' 设为默认")
diff --git a/routers/api/v1/admin/user/__init__.py b/routers/api/v1/admin/user/__init__.py
index 512bb26..8f20806 100644
--- a/routers/api/v1/admin/user/__init__.py
+++ b/routers/api/v1/admin/user/__init__.py
@@ -198,9 +198,17 @@ async def router_admin_delete_users(
:param request: 批量删除请求,包含待删除用户的 UUID 列表
:return: 删除结果(已删除数 / 总请求数)
"""
- deleted = 0
for uid in request.ids:
- user = await User.get(session, User.id == uid)
+ user = await User.get(session, User.id == uid, load=User.group)
+
+ # 安全检查:默认管理员不允许被删除(通过 Setting 中的 default_admin_id 识别)
+ default_admin_setting = await Setting.get(
+ session,
+ (Setting.type == SettingsType.AUTH) & (Setting.name == "default_admin_id")
+ )
+ if user and default_admin_setting and default_admin_setting.value == str(uid):
+ raise HTTPException(status_code=403, detail=f"默认管理员不允许被删除")
+
if user:
await User.delete(session, user)
l.info(f"管理员删除了用户: {user.email}")
@@ -235,6 +243,7 @@ async def router_admin_calibrate_storage(
previous_storage = user.storage
# 计算实际存储量 - 使用 SQL 聚合
+ # [TODO] 不应这么计算,看看 SQLModel_Ext 库怎么解决
from sqlmodel import select
result = await session.execute(
select(func.sum(Object.size), func.count(Object.id)).where(
diff --git a/routers/api/v1/directory/__init__.py b/routers/api/v1/directory/__init__.py
index 8599e9b..c703137 100644
--- a/routers/api/v1/directory/__init__.py
+++ b/routers/api/v1/directory/__init__.py
@@ -139,12 +139,13 @@ async def router_directory_get(
@directory_router.post(
path="/",
summary="创建目录",
+ status_code=204,
)
async def router_directory_create(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: DirectoryCreateRequest
-) -> ResponseBase:
+) -> None:
"""
创建目录
@@ -162,8 +163,11 @@ async def router_directory_create(
if "/" in name or "\\" in name:
raise HTTPException(status_code=400, detail="目录名称不能包含斜杠")
- # 通过 UUID 获取父目录
- parent = await Object.get(session, Object.id == request.parent_id)
+ # 通过 UUID 获取父目录(排除已删除的)
+ parent = await Object.get(
+ session,
+ (Object.id == request.parent_id) & (Object.deleted_at == None)
+ )
if not parent or parent.owner_id != user.id:
raise HTTPException(status_code=404, detail="父目录不存在")
@@ -173,12 +177,13 @@ async def router_directory_create(
if parent.is_banned:
http_exceptions.raise_banned("目标目录已被封禁,无法执行此操作")
- # 检查是否已存在同名对象
+ # 检查是否已存在同名对象(仅检查未删除的)
existing = await Object.get(
session,
(Object.owner_id == user.id) &
(Object.parent_id == parent.id) &
- (Object.name == name)
+ (Object.name == name) &
+ (Object.deleted_at == None)
)
if existing:
raise HTTPException(status_code=409, detail="同名文件或目录已存在")
@@ -193,14 +198,4 @@ async def router_directory_create(
parent_id=parent_id,
policy_id=policy_id,
)
- new_folder_id = new_folder.id # 在 save 前保存 UUID
- new_folder_name = new_folder.name
await new_folder.save(session)
-
- return ResponseBase(
- data={
- "id": new_folder_id,
- "name": new_folder_name,
- "parent_id": parent_id,
- }
- )
diff --git a/routers/api/v1/file/__init__.py b/routers/api/v1/file/__init__.py
index 5fa6fef..23077c7 100644
--- a/routers/api/v1/file/__init__.py
+++ b/routers/api/v1/file/__init__.py
@@ -32,7 +32,7 @@ from sqlmodels import (
UploadSessionResponse,
User,
)
-from service.storage import LocalStorageService
+from service.storage import LocalStorageService, adjust_user_storage
from service.redis.token_store import TokenStore
from utils.JWT import create_download_token, DOWNLOAD_TOKEN_TTL
from utils import http_exceptions
@@ -83,8 +83,11 @@ async def create_upload_session(
if not request.file_name or '/' in request.file_name or '\\' in request.file_name:
raise HTTPException(status_code=400, detail="无效的文件名")
- # 验证父目录
- parent = await Object.get(session, Object.id == request.parent_id)
+ # 验证父目录(排除已删除的)
+ parent = await Object.get(
+ session,
+ (Object.id == request.parent_id) & (Object.deleted_at == None)
+ )
if not parent or parent.owner_id != user.id:
raise HTTPException(status_code=404, detail="父目录不存在")
@@ -107,12 +110,18 @@ async def create_upload_session(
detail=f"文件大小超过限制 ({policy.max_size} bytes)"
)
- # 检查是否已存在同名文件
+ # 检查存储配额(auth_required 已预加载 user.group)
+ max_storage = user.group.max_storage
+ if max_storage > 0 and user.storage + request.file_size > max_storage:
+ http_exceptions.raise_insufficient_quota("存储空间不足")
+
+ # 检查是否已存在同名文件(仅检查未删除的)
existing = await Object.get(
session,
(Object.owner_id == user.id) &
(Object.parent_id == parent.id) &
- (Object.name == request.file_name)
+ (Object.name == request.file_name) &
+ (Object.deleted_at == None)
)
if existing:
raise HTTPException(status_code=409, detail="同名文件已存在")
@@ -269,6 +278,10 @@ async def upload_chunk(
commit=False
)
+ # 更新用户存储配额
+ if uploaded_size > 0:
+ await adjust_user_storage(session, user_id, uploaded_size, commit=False)
+
# 统一提交所有更改
await session.commit()
@@ -374,7 +387,10 @@ async def create_download_token_endpoint(
验证文件存在且属于当前用户后,生成 JWT 下载令牌。
"""
- file_obj = await Object.get(session, Object.id == file_id)
+ file_obj = await Object.get(
+ session,
+ (Object.id == file_id) & (Object.deleted_at == None)
+ )
if not file_obj or file_obj.owner_id != user.id:
raise HTTPException(status_code=404, detail="文件不存在")
@@ -418,8 +434,11 @@ async def download_file(
if not is_first_use:
raise HTTPException(status_code=404)
- # 获取文件对象
- file_obj = await Object.get(session, Object.id == file_id)
+ # 获取文件对象(排除已删除的)
+ file_obj = await Object.get(
+ session,
+ (Object.id == file_id) & (Object.deleted_at == None)
+ )
if not file_obj or file_obj.owner_id != owner_id:
raise HTTPException(status_code=404, detail="文件不存在")
@@ -481,8 +500,11 @@ async def create_empty_file(
if not request.name or '/' in request.name or '\\' in request.name:
raise HTTPException(status_code=400, detail="无效的文件名")
- # 验证父目录
- parent = await Object.get(session, Object.id == request.parent_id)
+ # 验证父目录(排除已删除的)
+ parent = await Object.get(
+ session,
+ (Object.id == request.parent_id) & (Object.deleted_at == None)
+ )
if not parent or parent.owner_id != user_id:
raise HTTPException(status_code=404, detail="父目录不存在")
@@ -492,12 +514,13 @@ async def create_empty_file(
if parent.is_banned:
http_exceptions.raise_banned("目标目录已被封禁,无法执行此操作")
- # 检查是否已存在同名文件
+ # 检查是否已存在同名文件(仅检查未删除的)
existing = await Object.get(
session,
(Object.owner_id == user_id) &
(Object.parent_id == parent.id) &
- (Object.name == request.name)
+ (Object.name == request.name) &
+ (Object.deleted_at == None)
)
if existing:
raise HTTPException(status_code=409, detail="同名文件已存在")
diff --git a/routers/api/v1/object/__init__.py b/routers/api/v1/object/__init__.py
index c097e3b..560bc0e 100644
--- a/routers/api/v1/object/__init__.py
+++ b/routers/api/v1/object/__init__.py
@@ -10,7 +10,6 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger as l
-from sqlmodel.ext.asyncio.session import AsyncSession
from middleware.auth import auth_required
from middleware.dependencies import SessionDep
@@ -30,7 +29,12 @@ from sqlmodels import (
ResponseBase,
User,
)
-from service.storage import LocalStorageService
+from service.storage import (
+ LocalStorageService,
+ adjust_user_storage,
+ copy_object_recursive,
+)
+from service.storage.object import soft_delete_objects
from utils import http_exceptions
object_router = APIRouter(
@@ -38,155 +42,17 @@ object_router = APIRouter(
tags=["object"]
)
-
-async def _delete_object_recursive(
- session: AsyncSession,
- obj: Object,
- user_id: UUID,
-) -> int:
- """
- 递归删除对象(软删除)
-
- 对于文件:
- - 减少 PhysicalFile 引用计数
- - 只有引用计数为0时才移动物理文件到回收站
-
- 对于目录:
- - 递归处理所有子对象
-
- :param session: 数据库会话
- :param obj: 要删除的对象
- :param user_id: 用户UUID
- :return: 删除的对象数量
- """
- deleted_count = 0
-
- # 在任何数据库操作前保存所有需要的属性,避免 commit 后对象过期导致懒加载失败
- obj_id = obj.id
- obj_name = obj.name
- obj_is_folder = obj.is_folder
- obj_is_file = obj.is_file
- obj_physical_file_id = obj.physical_file_id
-
- if obj_is_folder:
- # 递归删除子对象
- children = await Object.get_children(session, user_id, obj_id)
- for child in children:
- deleted_count += await _delete_object_recursive(session, child, user_id)
-
- # 如果是文件,处理物理文件引用
- if obj_is_file and obj_physical_file_id:
- physical_file = await PhysicalFile.get(session, PhysicalFile.id == obj_physical_file_id)
- if physical_file:
- # 减少引用计数
- new_count = physical_file.decrement_reference()
-
- if physical_file.can_be_deleted:
- # 引用计数为0,移动物理文件到回收站
- policy = await Policy.get(session, Policy.id == physical_file.policy_id)
- if policy and policy.type == PolicyType.LOCAL:
- try:
- storage_service = LocalStorageService(policy)
- await storage_service.move_to_trash(
- source_path=physical_file.storage_path,
- user_id=user_id,
- object_id=obj_id,
- )
- l.debug(f"物理文件已移动到回收站: {obj_name}")
- except Exception as e:
- l.warning(f"移动物理文件到回收站失败: {obj_name}, 错误: {e}")
-
- # 删除 PhysicalFile 记录
- await PhysicalFile.delete(session, physical_file)
- l.debug(f"物理文件记录已删除: {physical_file.storage_path}")
- else:
- # 还有其他引用,只更新引用计数
- await physical_file.save(session)
- l.debug(f"物理文件仍有 {new_count} 个引用,不删除: {physical_file.storage_path}")
-
- # 使用条件删除,避免访问过期的 obj 实例
- await Object.delete(session, condition=Object.id == obj_id)
- deleted_count += 1
-
- return deleted_count
-
-
-async def _copy_object_recursive(
- session: AsyncSession,
- src: Object,
- dst_parent_id: UUID,
- user_id: UUID,
-) -> tuple[int, list[UUID]]:
- """
- 递归复制对象
-
- 对于文件:
- - 增加 PhysicalFile 引用计数
- - 创建新的 Object 记录指向同一 PhysicalFile
-
- 对于目录:
- - 创建新目录
- - 递归复制所有子对象
-
- :param session: 数据库会话
- :param src: 源对象
- :param dst_parent_id: 目标父目录UUID
- :param user_id: 用户UUID
- :return: (复制数量, 新对象UUID列表)
- """
- copied_count = 0
- new_ids: list[UUID] = []
-
- # 在 save() 之前保存需要的属性值,避免 commit 后对象过期导致懒加载失败
- src_is_folder = src.is_folder
- src_id = src.id
-
- # 创建新的 Object 记录
- new_obj = Object(
- name=src.name,
- type=src.type,
- size=src.size,
- password=src.password,
- parent_id=dst_parent_id,
- owner_id=user_id,
- policy_id=src.policy_id,
- physical_file_id=src.physical_file_id,
- )
-
- # 如果是文件,增加物理文件引用计数
- if src.is_file and src.physical_file_id:
- physical_file = await PhysicalFile.get(session, PhysicalFile.id == src.physical_file_id)
- if physical_file:
- physical_file.increment_reference()
- await physical_file.save(session)
-
- new_obj = await new_obj.save(session)
- copied_count += 1
- new_ids.append(new_obj.id)
-
- # 如果是目录,递归复制子对象
- if src_is_folder:
- children = await Object.get_children(session, user_id, src_id)
- for child in children:
- child_count, child_ids = await _copy_object_recursive(
- session, child, new_obj.id, user_id
- )
- copied_count += child_count
- new_ids.extend(child_ids)
-
- return copied_count, new_ids
-
-
@object_router.post(
path='/',
summary='创建空白文件',
description='在指定目录下创建空白文件。',
+ status_code=204,
)
async def router_object_create(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: CreateFileRequest,
-) -> ResponseBase:
+) -> None:
"""
创建空白文件端点
@@ -201,8 +67,11 @@ async def router_object_create(
if not request.name or '/' in request.name or '\\' in request.name:
raise HTTPException(status_code=400, detail="无效的文件名")
- # 验证父目录
- parent = await Object.get(session, Object.id == request.parent_id)
+ # 验证父目录(排除已删除的)
+ parent = await Object.get(
+ session,
+ (Object.id == request.parent_id) & (Object.deleted_at == None)
+ )
if not parent or parent.owner_id != user_id:
raise HTTPException(status_code=404, detail="父目录不存在")
@@ -212,12 +81,13 @@ async def router_object_create(
if parent.is_banned:
http_exceptions.raise_banned("目标目录已被封禁,无法执行此操作")
- # 检查是否已存在同名文件
+ # 检查是否已存在同名文件(仅检查未删除的)
existing = await Object.get(
session,
(Object.owner_id == user_id) &
(Object.parent_id == parent.id) &
- (Object.name == request.name)
+ (Object.name == request.name) &
+ (Object.deleted_at == None)
)
if existing:
raise HTTPException(status_code=409, detail="同名文件已存在")
@@ -265,40 +135,41 @@ async def router_object_create(
l.info(f"创建空白文件: {request.name}")
- return ResponseBase()
-
@object_router.delete(
path='/',
summary='删除对象',
description='删除一个或多个对象(文件或目录),文件会移动到用户回收站。',
+ status_code=204,
)
async def router_object_delete(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: ObjectDeleteRequest,
-) -> ResponseBase:
+) -> None:
"""
- 删除对象端点(软删除)
+ 删除对象端点(软删除到回收站)
流程:
1. 验证对象存在且属于当前用户
- 2. 对于文件,减少物理文件引用计数
- 3. 如果引用计数为0,移动物理文件到 .trash 目录
- 4. 对于目录,递归处理子对象
- 5. 从数据库中删除记录
+ 2. 设置 deleted_at 时间戳
+ 3. 保存原 parent_id 到 deleted_original_parent_id
+ 4. 将 parent_id 置 NULL 脱离文件树
+ 5. 子对象和物理文件不做任何变更
:param session: 数据库会话
:param user: 当前登录用户
:param request: 删除请求(包含待删除对象的UUID列表)
:return: 删除结果
"""
- # 存储 user.id,避免后续 save() 导致 user 过期后无法访问
user_id = user.id
- deleted_count = 0
+ objects_to_delete: list[Object] = []
for obj_id in request.ids:
- obj = await Object.get(session, Object.id == obj_id)
+ obj = await Object.get(
+ session,
+ (Object.id == obj_id) & (Object.deleted_at == None)
+ )
if not obj or obj.owner_id != user_id:
continue
@@ -307,30 +178,24 @@ async def router_object_delete(
l.warning(f"尝试删除根目录被阻止: {obj.name}")
continue
- # 递归删除(包含引用计数逻辑)
- count = await _delete_object_recursive(session, obj, user_id)
- deleted_count += count
+ objects_to_delete.append(obj)
- l.info(f"用户 {user_id} 删除了 {deleted_count} 个对象")
-
- return ResponseBase(
- data={
- "deleted": deleted_count,
- "total": len(request.ids),
- }
- )
+ if objects_to_delete:
+ deleted_count = await soft_delete_objects(session, objects_to_delete)
+ l.info(f"用户 {user_id} 软删除了 {deleted_count} 个对象到回收站")
@object_router.patch(
path='/',
summary='移动对象',
description='移动一个或多个对象到目标目录',
+ status_code=204,
)
async def router_object_move(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: ObjectMoveRequest,
-) -> ResponseBase:
+) -> None:
"""
移动对象端点
@@ -342,8 +207,11 @@ async def router_object_move(
# 存储 user.id,避免后续 save() 导致 user 过期后无法访问
user_id = user.id
- # 验证目标目录
- dst = await Object.get(session, Object.id == request.dst_id)
+ # 验证目标目录(排除已删除的)
+ dst = await Object.get(
+ session,
+ (Object.id == request.dst_id) & (Object.deleted_at == None)
+ )
if not dst or dst.owner_id != user_id:
raise HTTPException(status_code=404, detail="目标目录不存在")
@@ -360,7 +228,10 @@ async def router_object_move(
moved_count = 0
for src_id in request.src_ids:
- src = await Object.get(session, Object.id == src_id)
+ src = await Object.get(
+ session,
+ (Object.id == src_id) & (Object.deleted_at == None)
+ )
if not src or src.owner_id != user_id:
continue
@@ -388,12 +259,13 @@ async def router_object_move(
if is_cycle:
continue
- # 检查目标目录下是否存在同名对象
+ # 检查目标目录下是否存在同名对象(仅检查未删除的)
existing = await Object.get(
session,
(Object.owner_id == user_id) &
(Object.parent_id == dst_id) &
- (Object.name == src.name)
+ (Object.name == src.name) &
+ (Object.deleted_at == None)
)
if existing:
continue # 跳过重名对象
@@ -405,24 +277,18 @@ async def router_object_move(
# 统一提交所有更改
await session.commit()
- return ResponseBase(
- data={
- "moved": moved_count,
- "total": len(request.src_ids),
- }
- )
-
@object_router.post(
path='/copy',
summary='复制对象',
description='复制一个或多个对象到目标目录。文件复制仅增加物理文件引用计数,不复制物理文件。',
+ status_code=204,
)
async def router_object_copy(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: ObjectCopyRequest,
-) -> ResponseBase:
+) -> None:
"""
复制对象端点
@@ -443,8 +309,11 @@ async def router_object_copy(
# 存储 user.id,避免后续 save() 导致 user 过期后无法访问
user_id = user.id
- # 验证目标目录
- dst = await Object.get(session, Object.id == request.dst_id)
+ # 验证目标目录(排除已删除的)
+ dst = await Object.get(
+ session,
+ (Object.id == request.dst_id) & (Object.deleted_at == None)
+ )
if not dst or dst.owner_id != user_id:
raise HTTPException(status_code=404, detail="目标目录不存在")
@@ -456,20 +325,25 @@ async def router_object_copy(
copied_count = 0
new_ids: list[UUID] = []
+ total_copied_size = 0
for src_id in request.src_ids:
- src = await Object.get(session, Object.id == src_id)
+ src = await Object.get(
+ session,
+ (Object.id == src_id) & (Object.deleted_at == None)
+ )
if not src or src.owner_id != user_id:
continue
if src.is_banned:
- continue
+ http_exceptions.raise_banned("源对象已被封禁,无法执行此操作")
# 不能复制根目录
if src.parent_id is None:
- continue
+ http_exceptions.raise_banned("无法复制根目录")
# 不能复制到自身
+ # [TODO] 视为创建副本
if src.id == dst.id:
continue
@@ -485,42 +359,42 @@ async def router_object_copy(
if is_cycle:
continue
- # 检查目标目录下是否存在同名对象
+ # 检查目标目录下是否存在同名对象(仅检查未删除的)
existing = await Object.get(
session,
(Object.owner_id == user_id) &
(Object.parent_id == dst.id) &
- (Object.name == src.name)
+ (Object.name == src.name) &
+ (Object.deleted_at == None)
)
if existing:
- continue # 跳过重名对象
+ # [TODO] 应当询问用户是否覆盖、跳过或创建副本
+ continue
# 递归复制
- count, ids = await _copy_object_recursive(session, src, dst.id, user_id)
+ count, ids, copied_size = await copy_object_recursive(session, src, dst.id, user_id)
copied_count += count
new_ids.extend(ids)
+ total_copied_size += copied_size
+
+ # 更新用户存储配额
+ if total_copied_size > 0:
+ await adjust_user_storage(session, user_id, total_copied_size)
l.info(f"用户 {user_id} 复制了 {copied_count} 个对象")
- return ResponseBase(
- data={
- "copied": copied_count,
- "total": len(request.src_ids),
- "new_ids": new_ids,
- }
- )
-
@object_router.post(
path='/rename',
summary='重命名对象',
description='重命名对象(文件或目录)。',
+ status_code=204,
)
async def router_object_rename(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: ObjectRenameRequest,
-) -> ResponseBase:
+) -> None:
"""
重命名对象端点
@@ -539,8 +413,11 @@ async def router_object_rename(
# 存储 user.id,避免后续 save() 导致 user 过期后无法访问
user_id = user.id
- # 验证对象存在
- obj = await Object.get(session, Object.id == request.id)
+ # 验证对象存在(排除已删除的)
+ obj = await Object.get(
+ session,
+ (Object.id == request.id) & (Object.deleted_at == None)
+ )
if not obj:
raise HTTPException(status_code=404, detail="对象不存在")
@@ -566,12 +443,13 @@ async def router_object_rename(
if obj.name == new_name:
return ResponseBase(data={"success": True})
- # 检查同目录下是否存在同名对象
+ # 检查同目录下是否存在同名对象(仅检查未删除的)
existing = await Object.get(
session,
(Object.owner_id == user_id) &
(Object.parent_id == obj.parent_id) &
- (Object.name == new_name)
+ (Object.name == new_name) &
+ (Object.deleted_at == None)
)
if existing:
raise HTTPException(status_code=409, detail="同名对象已存在")
@@ -582,8 +460,6 @@ async def router_object_rename(
l.info(f"用户 {user_id} 将对象 {obj.id} 重命名为 {new_name}")
- return ResponseBase(data={"success": True})
-
@object_router.get(
path='/property/{id}',
@@ -603,7 +479,10 @@ async def router_object_property(
:param id: 对象UUID
:return: 对象基本属性
"""
- obj = await Object.get(session, Object.id == id)
+ obj = await Object.get(
+ session,
+ (Object.id == id) & (Object.deleted_at == None)
+ )
if not obj:
raise HTTPException(status_code=404, detail="对象不存在")
@@ -641,7 +520,7 @@ async def router_object_property_detail(
"""
obj = await Object.get(
session,
- Object.id == id,
+ (Object.id == id) & (Object.deleted_at == None),
load=Object.file_metadata,
)
if not obj:
diff --git a/routers/api/v1/share/__init__.py b/routers/api/v1/share/__init__.py
index d0522f5..02d0190 100644
--- a/routers/api/v1/share/__init__.py
+++ b/routers/api/v1/share/__init__.py
@@ -1,5 +1,5 @@
from typing import Annotated, Literal
-from uuid import uuid4
+from uuid import UUID, uuid4
from datetime import datetime
from fastapi import APIRouter, Depends, Query, HTTPException
@@ -9,11 +9,14 @@ from middleware.auth import auth_required
from middleware.dependencies import SessionDep
from sqlmodels import ResponseBase
from sqlmodels.user import User
-from sqlmodels.share import Share, ShareCreateRequest, ShareResponse
-from sqlmodels.object import Object
+from sqlmodels.share import (
+ Share, ShareCreateRequest, CreateShareResponse, ShareResponse,
+ ShareDetailResponse, ShareOwnerInfo, ShareObjectItem,
+)
+from sqlmodels.object import Object, ObjectType
from sqlmodels.mixin import ListResponse, TableViewRequest
from utils import http_exceptions
-from utils.password.pwd import Password
+from utils.password.pwd import Password, PasswordStatus
share_router = APIRouter(
prefix='/share',
@@ -22,21 +25,97 @@ share_router = APIRouter(
@share_router.get(
path='/{id}',
- summary='获取分享',
- description='Get shared content by info type and ID.',
+ summary='获取分享详情',
+ description='Get share detail by share ID. No authentication required.',
)
-def router_share_get(info: str, id: str) -> ResponseBase:
+async def router_share_get(
+ session: SessionDep,
+ id: UUID,
+ password: str | None = Query(default=None),
+) -> ShareDetailResponse:
"""
- Get shared content by info type and ID.
-
- Args:
- info (str): The type of information being shared.
- id (str): The ID of the shared content.
-
- Returns:
- dict: A dictionary containing shared content information.
+ 获取分享详情
+
+ 认证:无需登录
+
+ 流程:
+ 1. 通过分享ID查找分享
+ 2. 检查过期、封禁状态
+ 3. 验证提取码(如果有)
+ 4. 返回分享详情(含文件树和分享者信息)
"""
- http_exceptions.raise_not_implemented()
+ # 1. 查询分享(预加载 user 和 object)
+ share = await Share.get(
+ session, Share.id == id,
+ load=[Share.user, Share.object],
+ )
+ if not share:
+ http_exceptions.raise_not_found(detail="分享不存在或已被取消")
+
+ # 2. 检查过期
+ now = datetime.now()
+ if share.expires and share.expires < now:
+ http_exceptions.raise_not_found(detail="分享已过期")
+
+ # 3. 获取关联对象
+ obj = await share.awaitable_attrs.object
+ user = await share.awaitable_attrs.user
+
+ # 4. 检查封禁和软删除
+ if obj and obj.is_banned:
+ http_exceptions.raise_banned()
+ if obj and obj.deleted_at:
+ http_exceptions.raise_not_found(detail="分享关联的文件已被删除")
+
+ # 5. 检查密码
+ if share.password:
+ if not password:
+ http_exceptions.raise_precondition_required(detail="请输入提取码")
+ if Password.verify(share.password, password) != PasswordStatus.VALID:
+ http_exceptions.raise_forbidden(detail="提取码错误")
+
+ # 6. 加载子对象(目录分享)
+ children_items: list[ShareObjectItem] = []
+ if obj and obj.type == ObjectType.FOLDER:
+ children = await Object.get_children(session, obj.owner_id, obj.id)
+ children_items = [
+ ShareObjectItem(
+ id=child.id,
+ name=child.name,
+ type=child.type,
+ size=child.size,
+ created_at=child.created_at,
+ updated_at=child.updated_at,
+ )
+ for child in children
+ ]
+
+ # 7. 构建响应(在 save 之前,避免 MissingGreenlet)
+ response = ShareDetailResponse(
+ expires=share.expires,
+ preview_enabled=share.preview_enabled,
+ score=share.score,
+ created_at=share.created_at,
+ owner=ShareOwnerInfo(
+ nickname=user.nickname if user else None,
+ avatar=user.avatar if user else "default",
+ ),
+ object=ShareObjectItem(
+ id=obj.id,
+ name=obj.name,
+ type=obj.type,
+ size=obj.size,
+ created_at=obj.created_at,
+ updated_at=obj.updated_at,
+ ),
+ children=children_items,
+ )
+
+ # 8. 递增浏览次数(最后执行,避免 MissingGreenlet)
+ share.views += 1
+ await share.save(session, refresh=False)
+
+ return response
@share_router.put(
path='/download/{id}',
@@ -226,7 +305,7 @@ async def router_share_create(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: ShareCreateRequest,
-) -> ShareResponse:
+) -> CreateShareResponse:
"""
创建新分享
@@ -237,10 +316,13 @@ async def router_share_create(
2. 生成随机分享码(uuid4)
3. 如果有密码则加密存储
4. 创建 Share 记录并保存
- 5. 返回分享信息
+ 5. 返回分享 ID
"""
- # 验证对象存在且属于当前用户
- obj = await Object.get(session, Object.id == request.object_id)
+ # 验证对象存在且属于当前用户(排除已删除的)
+ obj = await Object.get(
+ session,
+ (Object.id == request.object_id) & (Object.deleted_at == None)
+ )
if not obj or obj.owner_id != user.id:
raise HTTPException(status_code=404, detail="对象不存在或无权限")
@@ -256,11 +338,12 @@ async def router_share_create(
hashed_password = Password.hash(request.password)
# 创建分享记录
+ user_id = user.id
share = Share(
code=code,
password=hashed_password,
object_id=request.object_id,
- user_id=user.id,
+ user_id=user_id,
expires=request.expires,
remain_downloads=request.remain_downloads,
preview_enabled=request.preview_enabled,
@@ -269,24 +352,9 @@ async def router_share_create(
)
share = await share.save(session)
- l.info(f"用户 {user.id} 创建分享: {share.code}")
+ l.info(f"用户 {user_id} 创建分享: {share.code}")
- # 返回响应
- return ShareResponse(
- id=share.id,
- code=share.code,
- object_id=share.object_id,
- source_name=share.source_name,
- views=share.views,
- downloads=share.downloads,
- remain_downloads=share.remain_downloads,
- expires=share.expires,
- preview_enabled=share.preview_enabled,
- score=share.score,
- created_at=share.created_at,
- is_expired=share.expires is not None and share.expires < datetime.now(),
- has_password=share.password is not None,
- )
+ return CreateShareResponse(share_id=share.id)
@share_router.get(
path='/',
diff --git a/routers/api/v1/site/__init__.py b/routers/api/v1/site/__init__.py
index 068cc6d..1ec446d 100644
--- a/routers/api/v1/site/__init__.py
+++ b/routers/api/v1/site/__init__.py
@@ -1,7 +1,10 @@
from fastapi import APIRouter
from middleware.dependencies import SessionDep
-from sqlmodels import ResponseBase, Setting, SettingsType, SiteConfigResponse
+from sqlmodels import (
+ ResponseBase, Setting, SettingsType, SiteConfigResponse,
+ ThemePreset, ThemePresetResponse, ThemePresetListResponse,
+)
from sqlmodels.setting import CaptchaType
from utils import http_exceptions
@@ -41,6 +44,22 @@ def router_site_captcha():
"""
http_exceptions.raise_not_implemented()
+@site_router.get(
+ path='/themes',
+ summary='获取主题预设列表',
+)
+async def router_site_themes(session: SessionDep) -> ThemePresetListResponse:
+ """
+ 获取所有主题预设列表
+
+ 无需认证,前端初始化时调用。
+ """
+ presets: list[ThemePreset] = await ThemePreset.get(session, fetch_mode="all")
+ return ThemePresetListResponse(
+ themes=[ThemePresetResponse.from_preset(p) for p in presets]
+ )
+
+
@site_router.get(
path='/config',
summary='站点全局配置',
diff --git a/routers/api/v1/trash/__init__.py b/routers/api/v1/trash/__init__.py
new file mode 100644
index 0000000..461464b
--- /dev/null
+++ b/routers/api/v1/trash/__init__.py
@@ -0,0 +1,161 @@
+"""
+回收站路由
+
+提供回收站管理功能:列出、恢复、永久删除、清空。
+
+路由前缀:/trash
+"""
+from typing import Annotated
+
+from fastapi import APIRouter, Depends
+from loguru import logger as l
+
+from middleware.auth import auth_required
+from middleware.dependencies import SessionDep
+from sqlmodels import Object, User
+from sqlmodels.object import TrashDeleteRequest, TrashItemResponse, TrashRestoreRequest
+from service.storage.object import (
+ permanently_delete_objects,
+ restore_objects,
+ soft_delete_objects,
+)
+
+trash_router = APIRouter(
+ prefix="/trash",
+ tags=["trash"],
+)
+
+
+@trash_router.get(
+ path='/',
+ summary='列出回收站内容',
+ description='获取当前用户回收站中的所有顶层对象。',
+)
+async def router_trash_list(
+ session: SessionDep,
+ user: Annotated[User, Depends(auth_required)],
+) -> list[TrashItemResponse]:
+ """
+ 列出回收站内容
+
+ 认证:需要 JWT token
+
+ 返回回收站中被直接删除的顶层对象列表,
+ 不包含其子对象(子对象在恢复/永久删除时会随顶层对象一起处理)。
+ """
+ items = await Object.get_trash_items(session, user.id)
+
+ return [
+ TrashItemResponse(
+ id=item.id,
+ name=item.name,
+ type=item.type,
+ size=item.size,
+ deleted_at=item.deleted_at,
+ original_parent_id=item.deleted_original_parent_id,
+ )
+ for item in items
+ ]
+
+
+@trash_router.patch(
+ path='/restore',
+ summary='恢复对象',
+ description='从回收站恢复一个或多个对象到原位置。如果原位置不存在则恢复到根目录。',
+ status_code=204,
+)
+async def router_trash_restore(
+ session: SessionDep,
+ user: Annotated[User, Depends(auth_required)],
+ request: TrashRestoreRequest,
+) -> None:
+ """
+ 从回收站恢复对象
+
+ 认证:需要 JWT token
+
+ 流程:
+ 1. 验证对象存在且在回收站中(deleted_at IS NOT NULL)
+ 2. 检查原父目录是否存在且未删除
+ 3. 存在 → 恢复到原位置;不存在 → 恢复到根目录
+ 4. 处理同名冲突(自动重命名)
+ 5. 清除 deleted_at 和 deleted_original_parent_id
+ """
+ user_id = user.id
+ objects_to_restore: list[Object] = []
+
+ for obj_id in request.ids:
+ obj = await Object.get(
+ session,
+ (Object.id == obj_id) & (Object.owner_id == user_id) & (Object.deleted_at != None)
+ )
+ if obj:
+ objects_to_restore.append(obj)
+
+ if objects_to_restore:
+ restored_count = await restore_objects(session, objects_to_restore, user_id)
+ l.info(f"用户 {user_id} 从回收站恢复了 {restored_count} 个对象")
+
+
+@trash_router.delete(
+ path='/',
+ summary='永久删除对象',
+ description='永久删除回收站中的指定对象,包括物理文件和数据库记录。',
+ status_code=204,
+)
+async def router_trash_delete(
+ session: SessionDep,
+ user: Annotated[User, Depends(auth_required)],
+ request: TrashDeleteRequest,
+) -> None:
+ """
+ 永久删除回收站中的对象
+
+ 认证:需要 JWT token
+
+ 流程:
+ 1. 验证对象存在且在回收站中
+ 2. BFS 收集所有子文件的 PhysicalFile
+ 3. 处理引用计数,引用为 0 时物理删除文件
+ 4. 硬删除根 Object(CASCADE 清理子对象)
+ 5. 更新用户存储配额
+ """
+ user_id = user.id
+ objects_to_delete: list[Object] = []
+
+ for obj_id in request.ids:
+ obj = await Object.get(
+ session,
+ (Object.id == obj_id) & (Object.owner_id == user_id) & (Object.deleted_at != None)
+ )
+ if obj:
+ objects_to_delete.append(obj)
+
+ if objects_to_delete:
+ deleted_count = await permanently_delete_objects(session, objects_to_delete, user_id)
+ l.info(f"用户 {user_id} 永久删除了 {deleted_count} 个对象")
+
+
+@trash_router.delete(
+ path='/empty',
+ summary='清空回收站',
+ description='永久删除回收站中的所有对象。',
+ status_code=204,
+)
+async def router_trash_empty(
+ session: SessionDep,
+ user: Annotated[User, Depends(auth_required)],
+) -> None:
+ """
+ 清空回收站
+
+ 认证:需要 JWT token
+
+ 获取回收站中所有顶层对象,逐个执行永久删除。
+ """
+ user_id = user.id
+ trash_items = await Object.get_trash_items(session, user_id)
+
+ if trash_items:
+ deleted_count = await permanently_delete_objects(session, trash_items, user_id)
+ l.info(f"用户 {user_id} 清空回收站,共删除 {deleted_count} 个对象")
diff --git a/routers/api/v1/user/settings/__init__.py b/routers/api/v1/user/settings/__init__.py
index 7511af4..23becd3 100644
--- a/routers/api/v1/user/settings/__init__.py
+++ b/routers/api/v1/user/settings/__init__.py
@@ -1,12 +1,17 @@
from typing import Annotated
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, status
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
import sqlmodels
from middleware.auth import auth_required
from middleware.dependencies import SessionDep
+from sqlmodels import (
+ BUILTIN_DEFAULT_COLORS, ThemePreset, UserThemeUpdateRequest,
+)
+from sqlmodels.color import ThemeColorsBase
from utils import JWT, Password, http_exceptions
+from utils.password.pwd import PasswordStatus, TwoFactorResponse, TwoFactorVerifyRequest
user_settings_router = APIRouter(
prefix='/settings',
@@ -67,15 +72,50 @@ def router_user_settings_tasks() -> sqlmodels.ResponseBase:
summary='获取当前用户设定',
description='Get current user settings.',
)
-def router_user_settings(
+async def router_user_settings(
+ session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> sqlmodels.UserSettingResponse:
"""
- Get current user settings.
+ 获取当前用户设定
- Returns:
- dict: A dictionary containing the current user settings.
+ 主题颜色合并策略:
+ 1. 用户有颜色快照(7个字段均有值)→ 直接使用快照
+ 2. 否则查找默认预设 → 使用默认预设颜色
+ 3. 无默认预设 → 使用内置默认值
"""
+ # 计算主题颜色
+ has_snapshot = all([
+ user.color_primary, user.color_secondary, user.color_success,
+ user.color_info, user.color_warning, user.color_error, user.color_neutral,
+ ])
+ if has_snapshot:
+ theme_colors = ThemeColorsBase(
+ primary=user.color_primary,
+ secondary=user.color_secondary,
+ success=user.color_success,
+ info=user.color_info,
+ warning=user.color_warning,
+ error=user.color_error,
+ neutral=user.color_neutral,
+ )
+ else:
+ default_preset: ThemePreset | None = await ThemePreset.get(
+ session, ThemePreset.is_default == True # noqa: E712
+ )
+ if default_preset:
+ theme_colors = ThemeColorsBase(
+ primary=default_preset.primary,
+ secondary=default_preset.secondary,
+ success=default_preset.success,
+ info=default_preset.info,
+ warning=default_preset.warning,
+ error=default_preset.error,
+ neutral=default_preset.neutral,
+ )
+ else:
+ theme_colors = BUILTIN_DEFAULT_COLORS
+
return sqlmodels.UserSettingResponse(
id=user.id,
email=user.email,
@@ -86,6 +126,8 @@ def router_user_settings(
timezone=user.timezone,
group_expires=user.group_expires,
two_factor=user.two_factor is not None,
+ theme_preset_id=user.theme_preset_id,
+ theme_colors=theme_colors,
)
@@ -110,8 +152,9 @@ def router_user_settings_avatar() -> sqlmodels.ResponseBase:
summary='设定为Gravatar头像',
description='Set user avatar to Gravatar.',
dependencies=[Depends(auth_required)],
+ status_code=204,
)
-def router_user_settings_avatar_gravatar() -> sqlmodels.ResponseBase:
+def router_user_settings_avatar_gravatar() -> None:
"""
Set user avatar to Gravatar.
@@ -121,13 +164,56 @@ def router_user_settings_avatar_gravatar() -> sqlmodels.ResponseBase:
http_exceptions.raise_not_implemented()
+@user_settings_router.patch(
+ path='/theme',
+ summary='更新用户主题设置',
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def router_user_settings_theme(
+ session: SessionDep,
+ user: Annotated[sqlmodels.user.User, Depends(auth_required)],
+ request: UserThemeUpdateRequest,
+) -> None:
+ """
+ 更新用户主题设置
+
+ 请求体(均可选):
+ - theme_preset_id: 主题预设UUID
+ - theme_colors: 颜色配置对象(写入颜色快照)
+
+ 错误处理:
+ - 404: 指定的主题预设不存在
+ """
+ # 验证 preset_id 存在性
+ if request.theme_preset_id is not None:
+ preset: ThemePreset | None = await ThemePreset.get(
+ session, ThemePreset.id == request.theme_preset_id
+ )
+ if not preset:
+ http_exceptions.raise_not_found("主题预设不存在")
+ user.theme_preset_id = request.theme_preset_id
+
+ # 将颜色解构到快照列
+ if request.theme_colors is not None:
+ user.color_primary = request.theme_colors.primary
+ user.color_secondary = request.theme_colors.secondary
+ user.color_success = request.theme_colors.success
+ user.color_info = request.theme_colors.info
+ user.color_warning = request.theme_colors.warning
+ user.color_error = request.theme_colors.error
+ user.color_neutral = request.theme_colors.neutral
+
+ await user.save(session)
+
+
@user_settings_router.patch(
path='/{option}',
summary='更新用户设定',
description='Update user settings.',
dependencies=[Depends(auth_required)],
+ status_code=204,
)
-def router_user_settings_patch(option: str) -> sqlmodels.ResponseBase:
+def router_user_settings_patch(option: str) -> None:
"""
Update user settings.
@@ -148,17 +234,13 @@ def router_user_settings_patch(option: str) -> sqlmodels.ResponseBase:
)
async def router_user_settings_2fa(
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
-) -> sqlmodels.ResponseBase:
+) -> TwoFactorResponse:
"""
- Get two-factor authentication initialization information.
+ 获取两步验证初始化信息
- Returns:
- dict: A dictionary containing two-factor authentication setup information.
+ 返回 setup_token(用于后续验证请求)和 uri(用于生成二维码)。
"""
-
- return sqlmodels.ResponseBase(
- data=await Password.generate_totp(user.email)
- )
+ return await Password.generate_totp(name=user.email)
@user_settings_router.post(
@@ -166,38 +248,30 @@ async def router_user_settings_2fa(
summary='启用两步验证',
description='Enable two-factor authentication.',
dependencies=[Depends(auth_required)],
+ status_code=204,
)
async def router_user_settings_2fa_enable(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
- setup_token: str,
- code: str,
-) -> sqlmodels.ResponseBase:
+ request: TwoFactorVerifyRequest,
+) -> None:
"""
- Enable two-factor authentication for the user.
+ 启用两步验证
- Returns:
- dict: A dictionary containing the result of enabling two-factor authentication.
+ 请求体包含 setup_token(GET /2fa 返回的令牌)和 code(6 位验证码)。
"""
-
serializer = URLSafeTimedSerializer(JWT.SECRET_KEY)
try:
- # 1. 解包 Token,设置有效期(例如 600秒)
- secret = serializer.loads(setup_token, salt="2fa-setup-salt", max_age=600)
+ secret = serializer.loads(request.setup_token, salt="2fa-setup-salt", max_age=600)
except SignatureExpired:
raise HTTPException(status_code=400, detail="Setup session expired")
except BadSignature:
raise HTTPException(status_code=400, detail="Invalid token")
- # 2. 验证用户输入的 6 位验证码
- if not Password.verify_totp(secret, code):
+ if Password.verify_totp(secret, request.code) != PasswordStatus.VALID:
raise HTTPException(status_code=400, detail="Invalid OTP code")
# 3. 将 secret 存储到用户的数据库记录中,启用 2FA
user.two_factor = secret
- user = await user.save(session)
-
- return sqlmodels.ResponseBase(
- data={"message": "Two-factor authentication enabled successfully"}
- )
\ No newline at end of file
+ user = await user.save(session)
\ No newline at end of file
diff --git a/service/captcha/__init__.py b/service/captcha/__init__.py
index ec14615..3af2380 100644
--- a/service/captcha/__init__.py
+++ b/service/captcha/__init__.py
@@ -57,7 +57,7 @@ class CaptchaScene(StrEnum):
async def verify_captcha_if_needed(
session: AsyncSession,
scene: CaptchaScene,
- captcha_code: str | None,
+ captcha_code: str,
) -> None:
"""
通用验证码校验:查询设置判断是否需要,需要则校验。
@@ -81,23 +81,19 @@ async def verify_captcha_if_needed(
if not scene_setting or scene_setting.value != "1":
return
- # 2. 需要但未提供
- if not captcha_code:
- http_exceptions.raise_bad_request(detail="请完成验证码验证")
-
- # 3. 查询验证码类型和密钥
+ # 2. 查询验证码类型和密钥
captcha_settings: list[Setting] = await Setting.get(
session, Setting.type == SettingsType.CAPTCHA, fetch_mode="all",
)
s: dict[str, str | None] = {item.name: item.value for item in captcha_settings}
captcha_type = CaptchaType(s.get("captcha_type") or "default")
- # 4. DEFAULT 图片验证码尚未实现,跳过
+ # 3. DEFAULT 图片验证码尚未实现,跳过
if captcha_type == CaptchaType.DEFAULT:
l.warning("DEFAULT 图片验证码尚未实现,跳过验证")
return
- # 5. 选择验证器和密钥
+ # 4. 选择验证器和密钥
if captcha_type == CaptchaType.GCAPTCHA:
secret = s.get("captcha_ReCaptchaSecret")
verifier: CaptchaBase = GCaptcha()
@@ -112,7 +108,7 @@ async def verify_captcha_if_needed(
l.error(f"验证码密钥未配置: captcha_type={captcha_type}")
http_exceptions.raise_internal_error()
- # 6. 调用第三方 API 校验
+ # 5. 调用第三方 API 校验
is_valid = await verifier.verify_captcha(
CaptchaRequestBase(response=captcha_code, secret=secret)
)
diff --git a/service/captcha/default.py b/service/captcha/default.py
new file mode 100644
index 0000000..7ab44f2
--- /dev/null
+++ b/service/captcha/default.py
@@ -0,0 +1,5 @@
+from captcha.image import ImageCaptcha
+
+captcha = ImageCaptcha()
+
+print(captcha.generate())
\ No newline at end of file
diff --git a/service/storage/__init__.py b/service/storage/__init__.py
index 2b8091c..0a0c3a5 100644
--- a/service/storage/__init__.py
+++ b/service/storage/__init__.py
@@ -18,3 +18,11 @@ from .exceptions import (
)
from .local_storage import LocalStorageService
from .naming_rule import NamingContext, NamingRuleParser
+from .object import (
+ adjust_user_storage,
+ copy_object_recursive,
+ delete_object_recursive,
+ permanently_delete_objects,
+ restore_objects,
+ soft_delete_objects,
+)
\ No newline at end of file
diff --git a/service/storage/object.py b/service/storage/object.py
new file mode 100644
index 0000000..057954e
--- /dev/null
+++ b/service/storage/object.py
@@ -0,0 +1,491 @@
+from datetime import datetime
+from uuid import UUID
+
+from loguru import logger as l
+from sqlalchemy import update as sql_update
+from sqlalchemy.sql.functions import func
+from middleware.dependencies import SessionDep
+
+from service.storage import LocalStorageService
+from sqlmodels import (
+ Object,
+ PhysicalFile,
+ Policy,
+ PolicyType,
+ User,
+)
+
+
+async def adjust_user_storage(
+ session: SessionDep,
+ user_id: UUID,
+ delta: int,
+ commit: bool = True,
+) -> None:
+ """
+ 原子更新用户已用存储空间
+
+ 使用 SQL UPDATE SET storage = GREATEST(0, storage + delta) 避免竞态条件。
+
+ :param session: 数据库会话
+ :param user_id: 用户UUID
+ :param delta: 变化量(正数增加,负数减少)
+ :param commit: 是否立即提交
+ """
+ if delta == 0:
+ return
+
+ stmt = (
+ sql_update(User)
+ .where(User.id == user_id)
+ .values(storage=func.greatest(0, User.storage + delta))
+ )
+ await session.execute(stmt)
+
+ if commit:
+ await session.commit()
+
+ l.debug(f"用户 {user_id} 存储配额变更: {'+' if delta > 0 else ''}{delta} bytes")
+
+
+# ==================== 软删除 ====================
+
+async def soft_delete_objects(
+ session: SessionDep,
+ objects: list[Object],
+) -> int:
+ """
+ 软删除对象列表
+
+ 只标记顶层对象:设置 deleted_at、保存原 parent_id 到 deleted_original_parent_id、
+ 将 parent_id 置 NULL 脱离文件树。子对象保持不变,物理文件不移动。
+
+ :param session: 数据库会话
+ :param objects: 待软删除的对象列表
+ :return: 软删除的对象数量
+ """
+ deleted_count = 0
+ now = datetime.now()
+
+ for obj in objects:
+ obj.deleted_at = now
+ obj.deleted_original_parent_id = obj.parent_id
+ obj.parent_id = None
+ await obj.save(session, commit=False, refresh=False)
+ deleted_count += 1
+
+ await session.commit()
+ return deleted_count
+
+
+# ==================== 恢复 ====================
+
+async def _resolve_name_conflict(
+ session: SessionDep,
+ user_id: UUID,
+ parent_id: UUID,
+ name: str,
+) -> str:
+ """
+ 解决同名冲突,返回不冲突的名称
+
+ 命名规则:原名称 → 原名称 (1) → 原名称 (2) → ...
+ 对于有扩展名的文件:name.ext → name (1).ext → name (2).ext → ...
+
+ :param session: 数据库会话
+ :param user_id: 用户UUID
+ :param parent_id: 父目录UUID
+ :param name: 原始名称
+ :return: 不冲突的名称
+ """
+ existing = await Object.get(
+ session,
+ (Object.owner_id == user_id) &
+ (Object.parent_id == parent_id) &
+ (Object.name == name) &
+ (Object.deleted_at == None)
+ )
+ if not existing:
+ return name
+
+ # 分离文件名和扩展名
+ if '.' in name:
+ base, ext = name.rsplit('.', 1)
+ ext = f".{ext}"
+ else:
+ base = name
+ ext = ""
+
+ counter = 1
+ while True:
+ new_name = f"{base} ({counter}){ext}"
+ existing = await Object.get(
+ session,
+ (Object.owner_id == user_id) &
+ (Object.parent_id == parent_id) &
+ (Object.name == new_name) &
+ (Object.deleted_at == None)
+ )
+ if not existing:
+ return new_name
+ counter += 1
+
+
+async def restore_objects(
+ session: SessionDep,
+ objects: list[Object],
+ user_id: UUID,
+) -> int:
+ """
+ 从回收站恢复对象
+
+ 检查原父目录是否存在且未删除:
+ - 存在 → 恢复到原位置
+ - 不存在 → 恢复到用户根目录
+ 处理同名冲突(自动重命名)。
+
+ :param session: 数据库会话
+ :param objects: 待恢复的对象列表(必须是回收站中的顶层对象)
+ :param user_id: 用户UUID
+ :return: 恢复的对象数量
+ """
+ root = await Object.get_root(session, user_id)
+ if not root:
+ raise ValueError("用户根目录不存在")
+
+ restored_count = 0
+
+ for obj in objects:
+ if not obj.deleted_at:
+ continue
+
+ # 确定恢复目标目录
+ target_parent_id = root.id
+ if obj.deleted_original_parent_id:
+ original_parent = await Object.get(
+ session,
+ (Object.id == obj.deleted_original_parent_id) & (Object.deleted_at == None)
+ )
+ if original_parent:
+ target_parent_id = original_parent.id
+
+ # 解决同名冲突
+ resolved_name = await _resolve_name_conflict(
+ session, user_id, target_parent_id, obj.name
+ )
+
+ # 恢复对象
+ obj.parent_id = target_parent_id
+ obj.deleted_at = None
+ obj.deleted_original_parent_id = None
+ if resolved_name != obj.name:
+ obj.name = resolved_name
+ await obj.save(session, commit=False, refresh=False)
+ restored_count += 1
+
+ await session.commit()
+ return restored_count
+
+
+# ==================== 永久删除 ====================
+
+async def _collect_file_entries_all(
+ session: SessionDep,
+ user_id: UUID,
+ root: Object,
+) -> tuple[list[tuple[UUID, str, UUID]], int, int]:
+ """
+ BFS 收集子树中所有文件的物理文件信息(包含已删除和未删除的子对象)
+
+ 只执行 SELECT 查询,不触发 commit,ORM 对象始终有效。
+
+ :param session: 数据库会话
+ :param user_id: 用户UUID
+ :param root: 根对象
+ :return: (文件条目列表[(obj_id, name, physical_file_id)], 总对象数, 总文件大小)
+ """
+ file_entries: list[tuple[UUID, str, UUID]] = []
+ total_count = 1
+ total_file_size = 0
+
+ # 根对象本身是文件
+ if root.is_file and root.physical_file_id:
+ file_entries.append((root.id, root.name, root.physical_file_id))
+ total_file_size += root.size
+
+ # BFS 遍历子目录(使用 get_all_children 包含所有子对象)
+ if root.is_folder:
+ queue: list[UUID] = [root.id]
+ while queue:
+ parent_id = queue.pop(0)
+ children = await Object.get_all_children(session, user_id, parent_id)
+ for child in children:
+ total_count += 1
+ if child.is_file and child.physical_file_id:
+ file_entries.append((child.id, child.name, child.physical_file_id))
+ total_file_size += child.size
+ elif child.is_folder:
+ queue.append(child.id)
+
+ return file_entries, total_count, total_file_size
+
+
+async def permanently_delete_objects(
+ session: SessionDep,
+ objects: list[Object],
+ user_id: UUID,
+) -> int:
+ """
+ 永久删除回收站中的对象
+
+ 验证对象在回收站中(deleted_at IS NOT NULL),
+ BFS 收集所有子文件的 PhysicalFile 信息,
+ 处理引用计数,引用为 0 时物理删除文件,
+ 最后硬删除根 Object(CASCADE 自动清理子对象)。
+
+ :param session: 数据库会话
+ :param objects: 待永久删除的对象列表
+ :param user_id: 用户UUID
+ :return: 永久删除的对象数量
+ """
+ total_deleted = 0
+
+ for obj in objects:
+ if not obj.deleted_at:
+ l.warning(f"对象 {obj.id} 不在回收站中,跳过永久删除")
+ continue
+
+ root_id = obj.id
+ file_entries, obj_count, total_file_size = await _collect_file_entries_all(
+ session, user_id, obj
+ )
+
+ # 处理 PhysicalFile 引用计数
+ for obj_id, obj_name, physical_file_id in file_entries:
+ physical_file = await PhysicalFile.get(session, PhysicalFile.id == physical_file_id)
+ if not physical_file:
+ continue
+
+ physical_file.decrement_reference()
+
+ if physical_file.can_be_deleted:
+ # 物理删除文件
+ policy = await Policy.get(session, Policy.id == physical_file.policy_id)
+ if policy and policy.type == PolicyType.LOCAL:
+ try:
+ storage_service = LocalStorageService(policy)
+ await storage_service.delete_file(physical_file.storage_path)
+ l.debug(f"物理文件已删除: {obj_name}")
+ except Exception as e:
+ l.warning(f"物理删除文件失败: {obj_name}, 错误: {e}")
+
+ await PhysicalFile.delete(session, physical_file, commit=False)
+ l.debug(f"物理文件记录已删除: {physical_file.storage_path}")
+ else:
+ await physical_file.save(session, commit=False)
+ l.debug(f"物理文件仍有 {physical_file.reference_count} 个引用: {physical_file.storage_path}")
+
+ # 更新用户存储配额
+ if total_file_size > 0:
+ await adjust_user_storage(session, user_id, -total_file_size, commit=False)
+
+ # 硬删除根对象,CASCADE 自动删除所有子对象(不立即提交,避免其余对象过期)
+ await Object.delete(session, condition=Object.id == root_id, commit=False)
+
+ total_deleted += obj_count
+
+ # 统一提交所有变更
+ await session.commit()
+ return total_deleted
+
+
+# ==================== 旧接口(保持向后兼容) ====================
+
+async def _collect_file_entries(
+ session: SessionDep,
+ user_id: UUID,
+ root: Object,
+) -> tuple[list[tuple[UUID, str, UUID]], int, int]:
+ """
+ BFS 收集子树中所有文件的物理文件信息
+
+ 只执行 SELECT 查询,不触发 commit,ORM 对象始终有效。
+
+ :param session: 数据库会话
+ :param user_id: 用户UUID
+ :param root: 根对象
+ :return: (文件条目列表[(obj_id, name, physical_file_id)], 总对象数, 总文件大小)
+ """
+ file_entries: list[tuple[UUID, str, UUID]] = []
+ total_count = 1
+ total_file_size = 0
+
+ # 根对象本身是文件
+ if root.is_file and root.physical_file_id:
+ file_entries.append((root.id, root.name, root.physical_file_id))
+ total_file_size += root.size
+
+ # BFS 遍历子目录
+ if root.is_folder:
+ queue: list[UUID] = [root.id]
+ while queue:
+ parent_id = queue.pop(0)
+ children = await Object.get_children(session, user_id, parent_id)
+ for child in children:
+ total_count += 1
+ if child.is_file and child.physical_file_id:
+ file_entries.append((child.id, child.name, child.physical_file_id))
+ total_file_size += child.size
+ elif child.is_folder:
+ queue.append(child.id)
+
+ return file_entries, total_count, total_file_size
+
+
+async def delete_object_recursive(
+ session: SessionDep,
+ obj: Object,
+ user_id: UUID,
+) -> int:
+ """
+ 删除对象及其所有子对象(硬删除)
+
+ 两阶段策略:
+ 1. BFS 只读收集所有文件的 PhysicalFile 信息
+ 2. 批量处理引用计数(commit=False),最后删除根对象触发 CASCADE
+
+ :param session: 数据库会话
+ :param obj: 要删除的对象
+ :param user_id: 用户UUID
+ :return: 删除的对象数量
+ """
+ # 阶段一:只读收集(不触发任何 commit)
+ root_id = obj.id
+ file_entries, total_count, total_file_size = await _collect_file_entries(session, user_id, obj)
+
+ # 阶段二:批量处理 PhysicalFile 引用(全部 commit=False)
+ for obj_id, obj_name, physical_file_id in file_entries:
+ physical_file = await PhysicalFile.get(session, PhysicalFile.id == physical_file_id)
+ if not physical_file:
+ continue
+
+ physical_file.decrement_reference()
+
+ if physical_file.can_be_deleted:
+ # 物理删除文件
+ policy = await Policy.get(session, Policy.id == physical_file.policy_id)
+ if policy and policy.type == PolicyType.LOCAL:
+ try:
+ storage_service = LocalStorageService(policy)
+ await storage_service.delete_file(physical_file.storage_path)
+ l.debug(f"物理文件已删除: {obj_name}")
+ except Exception as e:
+ l.warning(f"物理删除文件失败: {obj_name}, 错误: {e}")
+
+ await PhysicalFile.delete(session, physical_file, commit=False)
+ l.debug(f"物理文件记录已删除: {physical_file.storage_path}")
+ else:
+ await physical_file.save(session, commit=False)
+ l.debug(f"物理文件仍有 {physical_file.reference_count} 个引用: {physical_file.storage_path}")
+
+ # 阶段三:更新用户存储配额(与删除在同一事务中)
+ if total_file_size > 0:
+ await adjust_user_storage(session, user_id, -total_file_size, commit=False)
+
+ # 阶段四:删除根对象,数据库 CASCADE 自动删除所有子对象
+ # commit=True(默认),一次性提交所有 PhysicalFile 变更 + Object 删除 + 配额更新
+ await Object.delete(session, condition=Object.id == root_id)
+
+ return total_count
+
+
+# ==================== 复制 ====================
+
+async def _copy_object_recursive(
+ session: SessionDep,
+ src: Object,
+ dst_parent_id: UUID,
+ user_id: UUID,
+) -> tuple[int, list[UUID], int]:
+ """
+ 递归复制对象(内部实现)
+
+ :param session: 数据库会话
+ :param src: 源对象
+ :param dst_parent_id: 目标父目录UUID
+ :param user_id: 用户UUID
+ :return: (复制数量, 新对象UUID列表, 复制的总文件大小)
+ """
+ copied_count = 0
+ new_ids: list[UUID] = []
+ total_copied_size = 0
+
+ # 在 save() 之前保存需要的属性值,避免 commit 后对象过期导致懒加载失败
+ src_is_folder = src.is_folder
+ src_is_file = src.is_file
+ src_id = src.id
+ src_size = src.size
+ src_physical_file_id = src.physical_file_id
+
+ # 创建新的 Object 记录
+ new_obj = Object(
+ name=src.name,
+ type=src.type,
+ size=src.size,
+ password=src.password,
+ parent_id=dst_parent_id,
+ owner_id=user_id,
+ policy_id=src.policy_id,
+ physical_file_id=src.physical_file_id,
+ )
+
+ # 如果是文件,增加物理文件引用计数
+ if src_is_file and src_physical_file_id:
+ physical_file = await PhysicalFile.get(session, PhysicalFile.id == src_physical_file_id)
+ if physical_file:
+ physical_file.increment_reference()
+ await physical_file.save(session)
+ total_copied_size += src_size
+
+ new_obj = await new_obj.save(session)
+ copied_count += 1
+ new_ids.append(new_obj.id)
+
+ # 如果是目录,递归复制子对象
+ if src_is_folder:
+ children = await Object.get_children(session, user_id, src_id)
+ for child in children:
+ child_count, child_ids, child_size = await _copy_object_recursive(
+ session, child, new_obj.id, user_id
+ )
+ copied_count += child_count
+ new_ids.extend(child_ids)
+ total_copied_size += child_size
+
+ return copied_count, new_ids, total_copied_size
+
+
+async def copy_object_recursive(
+ session: SessionDep,
+ src: Object,
+ dst_parent_id: UUID,
+ user_id: UUID,
+) -> tuple[int, list[UUID], int]:
+ """
+ 递归复制对象
+
+ 对于文件:
+ - 增加 PhysicalFile 引用计数
+ - 创建新的 Object 记录指向同一 PhysicalFile
+
+ 对于目录:
+ - 创建新目录
+ - 递归复制所有子对象
+
+ :param session: 数据库会话
+ :param src: 源对象
+ :param dst_parent_id: 目标父目录UUID
+ :param user_id: 用户UUID
+ :return: (复制数量, 新对象UUID列表, 复制的总文件大小)
+ """
+ return await _copy_object_recursive(session, src, dst_parent_id, user_id)
diff --git a/sqlmodels/__init__.py b/sqlmodels/__init__.py
index 3f8d559..91787f7 100644
--- a/sqlmodels/__init__.py
+++ b/sqlmodels/__init__.py
@@ -13,14 +13,21 @@ from .user import (
UserPublic,
UserResponse,
UserSettingResponse,
+ UserThemeUpdateRequest,
WebAuthnInfo,
+ UserTwoFactorResponse,
# 管理员DTO
UserAdminUpdateRequest,
UserCalibrateResponse,
UserAdminDetailResponse,
)
from .user_authn import AuthnResponse, UserAuthn
-from .color import ThemeResponse
+from .color import ChromaticColor, NeutralColor, ThemeColorsBase, BUILTIN_DEFAULT_COLORS
+from .theme_preset import (
+ ThemePreset, ThemePresetBase,
+ ThemePresetCreateRequest, ThemePresetUpdateRequest,
+ ThemePresetResponse, ThemePresetListResponse,
+)
from .download import (
Download,
@@ -68,6 +75,10 @@ from .object import (
AdminFileResponse,
AdminFileListResponse,
FileBanRequest,
+ # 回收站DTO
+ TrashItemResponse,
+ TrashRestoreRequest,
+ TrashDeleteRequest,
)
from .physical_file import PhysicalFile, PhysicalFileBase
from .uri import DiskNextURI, FileSystemNamespace
@@ -80,7 +91,11 @@ from .setting import (
# 管理员DTO
SettingItem, SettingsListResponse, SettingsUpdateRequest, SettingsUpdateResponse,
)
-from .share import Share, ShareBase, ShareCreateRequest, ShareResponse, AdminShareListItem
+from .share import (
+ Share, ShareBase, ShareCreateRequest, CreateShareResponse, ShareResponse,
+ ShareOwnerInfo, ShareObjectItem, ShareDetailResponse,
+ AdminShareListItem,
+)
from .source_link import SourceLink
from .storage_pack import StoragePack
from .tag import Tag, TagType
diff --git a/sqlmodels/color.py b/sqlmodels/color.py
index b3246ca..bc88dec 100644
--- a/sqlmodels/color.py
+++ b/sqlmodels/color.py
@@ -1,7 +1,71 @@
+from enum import StrEnum
+
from .base import SQLModelBase
-class ThemeResponse(SQLModelBase):
- """主题响应 DTO"""
- pass
-
\ No newline at end of file
+class ChromaticColor(StrEnum):
+ """有彩色枚举(17种 Tailwind 调色板颜色)"""
+
+ RED = "red"
+ ORANGE = "orange"
+ AMBER = "amber"
+ YELLOW = "yellow"
+ LIME = "lime"
+ GREEN = "green"
+ EMERALD = "emerald"
+ TEAL = "teal"
+ CYAN = "cyan"
+ SKY = "sky"
+ BLUE = "blue"
+ INDIGO = "indigo"
+ VIOLET = "violet"
+ PURPLE = "purple"
+ FUCHSIA = "fuchsia"
+ PINK = "pink"
+ ROSE = "rose"
+
+
+class NeutralColor(StrEnum):
+ """无彩色枚举(5种灰色调)"""
+
+ SLATE = "slate"
+ GRAY = "gray"
+ ZINC = "zinc"
+ NEUTRAL = "neutral"
+ STONE = "stone"
+
+
+class ThemeColorsBase(SQLModelBase):
+ """嵌套颜色 DTO,API 请求/响应层使用"""
+
+ primary: ChromaticColor
+ """主色调"""
+
+ secondary: ChromaticColor
+ """辅助色"""
+
+ success: ChromaticColor
+ """成功色"""
+
+ info: ChromaticColor
+ """信息色"""
+
+ warning: ChromaticColor
+ """警告色"""
+
+ error: ChromaticColor
+ """错误色"""
+
+ neutral: NeutralColor
+ """中性色"""
+
+
+BUILTIN_DEFAULT_COLORS = ThemeColorsBase(
+ primary=ChromaticColor.GREEN,
+ secondary=ChromaticColor.BLUE,
+ success=ChromaticColor.GREEN,
+ info=ChromaticColor.BLUE,
+ warning=ChromaticColor.YELLOW,
+ error=ChromaticColor.RED,
+ neutral=NeutralColor.ZINC,
+)
diff --git a/sqlmodels/migration.py b/sqlmodels/migration.py
index 4f5969f..2a49509 100644
--- a/sqlmodels/migration.py
+++ b/sqlmodels/migration.py
@@ -1,6 +1,4 @@
-
from .setting import Setting, SettingsType
-from .color import ThemeResponse
from utils.conf.appmeta import BackendVersion
from utils.password.pwd import Password
from loguru import logger as log
@@ -18,6 +16,7 @@ async def migration() -> None:
await init_default_policy()
await init_default_group()
await init_default_user()
+ await init_default_theme_presets()
log.info('数据库初始化结束')
@@ -58,27 +57,14 @@ default_settings: list[Setting] = [
Setting(name="reset_after_upload_failed", value="0", type=SettingsType.UPLOAD),
Setting(name="login_captcha", value="0", type=SettingsType.LOGIN),
Setting(name="reg_captcha", value="0", type=SettingsType.LOGIN),
+ Setting(name="reg_email_captcha", value="0", type=SettingsType.LOGIN),
Setting(name="email_active", value="0", type=SettingsType.REGISTER),
- Setting(name="mail_activation_template", value="""
激活您的账户 | | 激活{siteTitle}账户 | | 亲爱的{userName}: | | 感谢您注册{siteTitle},请点击下方按钮完成账户激活。 | | 激活账户 | | 感谢您选择{siteTitle}。 |
|
| |
""", type=SettingsType.MAIL_TEMPLATE),
+ Setting(name="mail_activation_template", value="""验证码 验证您的邮箱| | | 感谢您注册{{ site_name }},您的验证码是: | | |
该验证码{{ valid_minutes }} 分钟内有效。 为保障您的账户安全,请勿将验证码告诉他人。 |
此邮件由系统自动发送,请勿直接回复。 © {{ current_year }} {{ site_name }}. 保留所有权利。 |
|
|
""", type=SettingsType.MAIL_TEMPLATE),
+ Setting(name="mail_reset_pwd_template", value="""重置密码 重置密码| | | 您正在申请重置{{ site_name }} 的登录密码。若确认是您本人操作,请使用下方验证码: | | |
该验证码{{ valid_minutes }} 分钟内有效。 如果您没有请求重置密码,请忽略此邮件,您的账户依然安全。 |
此邮件由系统自动发送,请勿直接回复。 © {{ current_year }} {{ site_name }}. 保留所有权利。 |
|
|
""", type=SettingsType.MAIL_TEMPLATE),
Setting(name="forget_captcha", value="0", type=SettingsType.LOGIN),
- Setting(name="mail_reset_pwd_template", value="""重设密码 | | 重设{siteTitle}密码 | | 亲爱的{userName}: | | 请点击下方按钮完成密码重设。如果非你本人操作,请忽略此邮件。 | | 重设密码 | | 感谢您选择{siteTitle}。 |
|
| |
""", type=SettingsType.MAIL_TEMPLATE),
Setting(name=f"db_version_{BackendVersion}", value="installed", type=SettingsType.VERSION),
Setting(name="hot_share_num", value="10", type=SettingsType.SHARE),
Setting(name="gravatar_server", value="https://www.gravatar.com/", type=SettingsType.AVATAR),
- Setting(name="defaultTheme", value="#3f51b5", type=SettingsType.BASIC),
- Setting(name="themes", value=ThemeResponse().model_dump_json(), type=SettingsType.BASIC),
Setting(name="aria2_token", value="", type=SettingsType.ARIA2),
Setting(name="aria2_rpcurl", value="", type=SettingsType.ARIA2),
Setting(name="aria2_temp_path", value="", type=SettingsType.ARIA2),
@@ -327,4 +313,32 @@ async def init_default_policy() -> None:
storage_service = LocalStorageService(local_policy)
await storage_service.ensure_base_directory()
- log.info('已创建默认本地存储策略,存储目录:./data')
\ No newline at end of file
+ log.info('已创建默认本地存储策略,存储目录:./data')
+
+
+async def init_default_theme_presets() -> None:
+ from .color import ChromaticColor, NeutralColor
+ from .theme_preset import ThemePreset
+ from .database_connection import DatabaseManager
+
+ log.info('初始化默认主题预设...')
+
+ async for session in DatabaseManager.get_session():
+ # 已存在预设则跳过
+ existing_count = await ThemePreset.count(session)
+ if existing_count > 0:
+ return
+
+ default_preset = ThemePreset(
+ name="默认主题",
+ is_default=True,
+ primary=ChromaticColor.GREEN,
+ secondary=ChromaticColor.BLUE,
+ success=ChromaticColor.GREEN,
+ info=ChromaticColor.BLUE,
+ warning=ChromaticColor.YELLOW,
+ error=ChromaticColor.RED,
+ neutral=NeutralColor.ZINC,
+ )
+ await default_preset.save(session)
+ log.info('已创建默认主题预设')
\ No newline at end of file
diff --git a/sqlmodels/object.py b/sqlmodels/object.py
index 114a800..5752596 100644
--- a/sqlmodels/object.py
+++ b/sqlmodels/object.py
@@ -5,7 +5,7 @@ from uuid import UUID
from enum import StrEnum
from sqlalchemy import BigInteger
-from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint, Index, text
+from sqlmodel import Field, Relationship, CheckConstraint, Index, text
from .base import SQLModelBase
from .mixin import UUIDTableBaseMixin
@@ -195,8 +195,13 @@ class Object(ObjectBase, UUIDTableBaseMixin):
"""
__table_args__ = (
- # 同一父目录下名称唯一(包括 parent_id 为 NULL 的情况)
- UniqueConstraint("owner_id", "parent_id", "name", name="uq_object_parent_name"),
+ # 同一父目录下名称唯一(仅对未删除记录生效)
+ Index(
+ "uq_object_parent_name_active",
+ "owner_id", "parent_id", "name",
+ unique=True,
+ postgresql_where=text("deleted_at IS NULL"),
+ ),
# 名称不能包含斜杠(根目录 parent_id IS NULL 除外,因为根目录 name="/")
CheckConstraint(
"parent_id IS NULL OR (name NOT LIKE '%/%' AND name NOT LIKE '%\\%')",
@@ -207,6 +212,8 @@ class Object(ObjectBase, UUIDTableBaseMixin):
Index("ix_object_parent_updated", "parent_id", "updated_at"),
Index("ix_object_owner_type", "owner_id", "type"),
Index("ix_object_owner_size", "owner_id", "size"),
+ # 回收站查询索引
+ Index("ix_object_owner_deleted", "owner_id", "deleted_at"),
)
# ==================== 基础字段 ====================
@@ -280,6 +287,18 @@ class Object(ObjectBase, UUIDTableBaseMixin):
ban_reason: str | None = Field(default=None, max_length=500)
"""封禁原因"""
+ # ==================== 软删除相关字段 ====================
+
+ deleted_at: datetime | None = Field(default=None, index=True)
+ """软删除时间戳,NULL 表示未删除"""
+
+ deleted_original_parent_id: UUID | None = Field(
+ default=None,
+ foreign_key="object.id",
+ ondelete="SET NULL",
+ )
+ """软删除前的原始父目录UUID(恢复时用于还原位置)"""
+
# ==================== 关系 ====================
owner: "User" = Relationship(
@@ -299,13 +318,19 @@ class Object(ObjectBase, UUIDTableBaseMixin):
# 自引用关系
parent: "Object" = Relationship(
back_populates="children",
- sa_relationship_kwargs={"remote_side": "Object.id"},
+ sa_relationship_kwargs={
+ "remote_side": "Object.id",
+ "foreign_keys": "[Object.parent_id]",
+ },
)
"""父目录"""
children: list["Object"] = Relationship(
back_populates="parent",
- sa_relationship_kwargs={"cascade": "all, delete-orphan"}
+ sa_relationship_kwargs={
+ "cascade": "all, delete-orphan",
+ "foreign_keys": "[Object.parent_id]",
+ },
)
"""子对象(文件和子目录)"""
@@ -367,7 +392,7 @@ class Object(ObjectBase, UUIDTableBaseMixin):
"""
return await cls.get(
session,
- (cls.owner_id == user_id) & (cls.parent_id == None)
+ (cls.owner_id == user_id) & (cls.parent_id == None) & (cls.deleted_at == None)
)
@classmethod
@@ -416,7 +441,8 @@ class Object(ObjectBase, UUIDTableBaseMixin):
session,
(cls.owner_id == user_id) &
(cls.parent_id == current.id) &
- (cls.name == part)
+ (cls.name == part) &
+ (cls.deleted_at == None)
)
return current
@@ -424,7 +450,23 @@ class Object(ObjectBase, UUIDTableBaseMixin):
@classmethod
async def get_children(cls, session, user_id: UUID, parent_id: UUID) -> list["Object"]:
"""
- 获取目录下的所有子对象
+ 获取目录下的所有子对象(不包含已软删除的)
+
+ :param session: 数据库会话
+ :param user_id: 用户UUID
+ :param parent_id: 父目录UUID
+ :return: 子对象列表
+ """
+ return await cls.get(
+ session,
+ (cls.owner_id == user_id) & (cls.parent_id == parent_id) & (cls.deleted_at == None),
+ fetch_mode="all"
+ )
+
+ @classmethod
+ async def get_all_children(cls, session, user_id: UUID, parent_id: UUID) -> list["Object"]:
+ """
+ 获取目录下的所有子对象(包含已软删除的,用于永久删除场景)
:param session: 数据库会话
:param user_id: 用户UUID
@@ -437,6 +479,24 @@ class Object(ObjectBase, UUIDTableBaseMixin):
fetch_mode="all"
)
+ @classmethod
+ async def get_trash_items(cls, session, user_id: UUID) -> list["Object"]:
+ """
+ 获取用户回收站中的顶层对象
+
+ 只返回被直接软删除的顶层对象(deleted_at 非 NULL),
+ 不返回其子对象(子对象的 deleted_at 为 NULL,通过 parent 关系间接处于回收站中)。
+
+ :param session: 数据库会话
+ :param user_id: 用户UUID
+ :return: 回收站顶层对象列表
+ """
+ return await cls.get(
+ session,
+ (cls.owner_id == user_id) & (cls.deleted_at != None),
+ fetch_mode="all"
+ )
+
@classmethod
async def resolve_uri(
cls,
@@ -805,3 +865,41 @@ class AdminFileListResponse(SQLModelBase):
total: int = 0
"""总数"""
+
+
+# ==================== 回收站相关 DTO ====================
+
+class TrashItemResponse(SQLModelBase):
+ """回收站对象响应 DTO"""
+
+ id: UUID
+ """对象UUID"""
+
+ name: str
+ """对象名称"""
+
+ type: ObjectType
+ """对象类型"""
+
+ size: int
+ """文件大小(字节)"""
+
+ deleted_at: datetime
+ """删除时间"""
+
+ original_parent_id: UUID | None
+ """原始父目录UUID"""
+
+
+class TrashRestoreRequest(SQLModelBase):
+ """恢复对象请求 DTO"""
+
+ ids: list[UUID]
+ """待恢复对象UUID列表"""
+
+
+class TrashDeleteRequest(SQLModelBase):
+ """永久删除对象请求 DTO"""
+
+ ids: list[UUID]
+ """待永久删除对象UUID列表"""
diff --git a/sqlmodels/report.py b/sqlmodels/report.py
index f7a9b2e..c928bdf 100644
--- a/sqlmodels/report.py
+++ b/sqlmodels/report.py
@@ -1,5 +1,6 @@
from enum import StrEnum
from typing import TYPE_CHECKING
+from uuid import UUID
from sqlmodel import Field, Relationship
@@ -24,7 +25,7 @@ class Report(SQLModelBase, TableBaseMixin):
description: str | None = Field(default=None, max_length=255, description="补充描述")
# 外键
- share_id: int = Field(
+ share_id: UUID = Field(
foreign_key="share.id",
index=True,
ondelete="CASCADE"
diff --git a/sqlmodels/share.py b/sqlmodels/share.py
index a3cd946..8e757be 100644
--- a/sqlmodels/share.py
+++ b/sqlmodels/share.py
@@ -6,7 +6,9 @@ from uuid import UUID
from sqlmodel import Field, Relationship, text, UniqueConstraint, Index
from .base import SQLModelBase
-from .mixin import TableBaseMixin
+from .model_base import ResponseBase
+from .mixin import UUIDTableBaseMixin
+from .object import ObjectType
if TYPE_CHECKING:
from .user import User
@@ -34,13 +36,13 @@ class ShareBase(SQLModelBase):
preview_enabled: bool = True
"""是否允许预览"""
- score: int = 0
+ score: int = Field(default=0, ge=0)
"""兑换此分享所需的积分"""
# ==================== 数据库模型 ====================
-class Share(SQLModelBase, TableBaseMixin):
+class Share(SQLModelBase, UUIDTableBaseMixin):
"""分享模型"""
__table_args__ = (
@@ -81,7 +83,7 @@ class Share(SQLModelBase, TableBaseMixin):
source_name: str | None = Field(default=None, max_length=255)
"""源名称(冗余字段,便于展示)"""
- score: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
+ score: int = Field(default=0, ge=0)
"""兑换此分享所需的积分"""
# 外键
@@ -119,10 +121,17 @@ class ShareCreateRequest(ShareBase):
pass
-class ShareResponse(SQLModelBase):
- """分享响应 DTO"""
+class CreateShareResponse(ResponseBase):
+ """创建分享响应 DTO"""
- id: int
+ share_id: UUID
+ """新创建的分享记录 ID"""
+
+
+class ShareResponse(SQLModelBase):
+ """查看分享响应 DTO"""
+
+ id: UUID
"""分享ID"""
code: str
@@ -162,10 +171,67 @@ class ShareResponse(SQLModelBase):
"""是否有密码"""
+class ShareOwnerInfo(SQLModelBase):
+ """分享者公开信息 DTO"""
+
+ nickname: str | None
+ """昵称"""
+
+ avatar: str
+ """头像"""
+
+
+class ShareObjectItem(SQLModelBase):
+ """分享中的文件/文件夹信息 DTO"""
+
+ id: UUID
+ """对象UUID"""
+
+ name: str
+ """名称"""
+
+ type: ObjectType
+ """类型:file 或 folder"""
+
+ size: int
+ """文件大小(字节),目录为 0"""
+
+ created_at: datetime
+ """创建时间"""
+
+ updated_at: datetime
+ """修改时间"""
+
+
+class ShareDetailResponse(SQLModelBase):
+ """获取分享详情响应 DTO(面向访客,隐藏内部统计数据)"""
+
+ expires: datetime | None
+ """过期时间"""
+
+ preview_enabled: bool
+ """是否允许预览"""
+
+ score: int
+ """积分"""
+
+ created_at: datetime
+ """创建时间"""
+
+ owner: ShareOwnerInfo
+ """分享者信息"""
+
+ object: ShareObjectItem
+ """分享的根对象"""
+
+ children: list[ShareObjectItem]
+ """子文件/文件夹列表(仅目录分享有内容)"""
+
+
class ShareListItemBase(SQLModelBase):
"""分享列表项基础字段"""
- id: int
+ id: UUID
"""分享ID"""
code: str
diff --git a/sqlmodels/theme_preset.py b/sqlmodels/theme_preset.py
new file mode 100644
index 0000000..bfec1fc
--- /dev/null
+++ b/sqlmodels/theme_preset.py
@@ -0,0 +1,117 @@
+from datetime import datetime
+from uuid import UUID
+
+from sqlmodel import Field
+
+from .base import SQLModelBase
+from .color import ChromaticColor, NeutralColor, ThemeColorsBase
+from .mixin import UUIDTableBaseMixin
+
+
+class ThemePresetBase(SQLModelBase):
+ """主题预设基础字段"""
+
+ name: str = Field(max_length=100)
+ """预设名称"""
+
+ is_default: bool = False
+ """是否为默认预设"""
+
+ primary: ChromaticColor
+ """主色调"""
+
+ secondary: ChromaticColor
+ """辅助色"""
+
+ success: ChromaticColor
+ """成功色"""
+
+ info: ChromaticColor
+ """信息色"""
+
+ warning: ChromaticColor
+ """警告色"""
+
+ error: ChromaticColor
+ """错误色"""
+
+ neutral: NeutralColor
+ """中性色"""
+
+
+class ThemePreset(ThemePresetBase, UUIDTableBaseMixin):
+ """主题预设表"""
+
+ name: str = Field(max_length=100, unique=True)
+ """预设名称(唯一约束)"""
+
+
+# ==================== DTO ====================
+
+class ThemePresetCreateRequest(SQLModelBase):
+ """创建主题预设请求 DTO"""
+
+ name: str = Field(max_length=100)
+ """预设名称"""
+
+ colors: ThemeColorsBase
+ """颜色配置"""
+
+
+class ThemePresetUpdateRequest(SQLModelBase):
+ """更新主题预设请求 DTO"""
+
+ name: str | None = Field(default=None, max_length=100)
+ """预设名称(可选)"""
+
+ colors: ThemeColorsBase | None = None
+ """颜色配置(可选)"""
+
+
+class ThemePresetResponse(SQLModelBase):
+ """主题预设响应 DTO"""
+
+ id: UUID
+ """预设UUID"""
+
+ name: str
+ """预设名称"""
+
+ is_default: bool
+ """是否为默认预设"""
+
+ colors: ThemeColorsBase
+ """颜色配置"""
+
+ created_at: datetime
+ """创建时间"""
+
+ updated_at: datetime
+ """更新时间"""
+
+ @classmethod
+ def from_preset(cls, preset: ThemePreset) -> 'ThemePresetResponse':
+ """从数据库模型转换为响应 DTO(平铺列 → 嵌套 colors 对象)"""
+ return cls(
+ id=preset.id,
+ name=preset.name,
+ is_default=preset.is_default,
+ colors=ThemeColorsBase(
+ primary=preset.primary,
+ secondary=preset.secondary,
+ success=preset.success,
+ info=preset.info,
+ warning=preset.warning,
+ error=preset.error,
+ neutral=preset.neutral,
+ ),
+ created_at=preset.created_at,
+ updated_at=preset.updated_at,
+ )
+
+
+class ThemePresetListResponse(SQLModelBase):
+ """主题预设列表响应 DTO"""
+
+ themes: list[ThemePresetResponse]
+ """主题预设列表"""
diff --git a/sqlmodels/user.py b/sqlmodels/user.py
index b5210fc..1dade14 100644
--- a/sqlmodels/user.py
+++ b/sqlmodels/user.py
@@ -10,6 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.main import RelationshipInfo
from .base import SQLModelBase
+from .color import ChromaticColor, NeutralColor, ThemeColorsBase
from .model_base import ResponseBase
from .mixin import UUIDTableBaseMixin, TableViewRequest, ListResponse
@@ -34,13 +35,6 @@ class AvatarType(StrEnum):
GRAVATAR = "gravatar"
FILE = "file"
-class ThemeType(StrEnum):
- """主题类型枚举"""
-
- LIGHT = "light"
- DARK = "dark"
- SYSTEM = "system"
-
class UserStatus(StrEnum):
"""用户状态枚举"""
@@ -99,7 +93,7 @@ class LoginRequest(SQLModelBase):
captcha: str | None = None
"""验证码"""
- two_fa_code: int | None = Field(default=None, min_length=6, max_length=6)
+ two_fa_code: str | None = Field(default=None, min_length=6, max_length=6)
"""两步验证代码"""
@@ -297,6 +291,29 @@ class UserSettingResponse(SQLModelBase):
two_factor: bool = False
"""是否启用两步验证"""
+ theme_preset_id: UUID | None = None
+ """选用的主题预设UUID"""
+
+ theme_colors: ThemeColorsBase | None = None
+ """当前生效的颜色配置"""
+
+
+class UserThemeUpdateRequest(SQLModelBase):
+ """用户更新主题请求 DTO"""
+
+ theme_preset_id: UUID | None = None
+ """主题预设UUID"""
+
+ theme_colors: ThemeColorsBase | None = None
+ """颜色配置"""
+
+
+class UserTwoFactorResponse(SQLModelBase):
+ """用户两步验证信息 DTO"""
+
+ two_factor_key: str
+ """两步验证密钥"""
+
# ==================== 管理员用户管理 DTO ====================
@@ -428,8 +445,31 @@ class User(UserBase, UUIDTableBaseMixin):
"""当前用户组过期时间"""
# Option 相关字段
- # theme: ThemeType = Field(default=ThemeType.SYSTEM)
- # """主题类型: light/dark/system"""
+ theme_preset_id: UUID | None = Field(
+ default=None, foreign_key="themepreset.id", ondelete="SET NULL"
+ )
+ """选用的主题预设UUID"""
+
+ color_primary: ChromaticColor | None = None
+ """颜色快照:主色调"""
+
+ color_secondary: ChromaticColor | None = None
+ """颜色快照:辅助色"""
+
+ color_success: ChromaticColor | None = None
+ """颜色快照:成功色"""
+
+ color_info: ChromaticColor | None = None
+ """颜色快照:信息色"""
+
+ color_warning: ChromaticColor | None = None
+ """颜色快照:警告色"""
+
+ color_error: ChromaticColor | None = None
+ """颜色快照:错误色"""
+
+ color_neutral: NeutralColor | None = None
+ """颜色快照:中性色"""
language: str = Field(default="zh-CN", max_length=5)
"""语言偏好"""
diff --git a/utils/http/http_exceptions.py b/utils/http/http_exceptions.py
index e76f83a..d7a594f 100644
--- a/utils/http/http_exceptions.py
+++ b/utils/http/http_exceptions.py
@@ -4,64 +4,64 @@ from fastapi import HTTPException, status
# --- 400 ---
-def ensure_request_param(to_check: Any, *args, **kwargs) -> None:
+def ensure_request_param(to_check: Any, detail: str | None = None) -> None:
"""
Ensures a parameter exists. If not, raises a 400 Bad Request.
This function returns None if the check passes.
"""
if not to_check:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
-def raise_bad_request(*args, **kwargs) -> NoReturn:
+def raise_bad_request(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 400 Bad Request exception."""
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
-def raise_unauthorized(detail: str | None = None, *args, **kwargs) -> NoReturn:
+def raise_unauthorized(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 401 Unauthorized exception."""
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
-def raise_insufficient_quota(detail: str | None = None, *args, **kwargs) -> NoReturn:
+def raise_insufficient_quota(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 402 Payment Required exception."""
- raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=detail, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=detail)
-def raise_forbidden(detail: str | None = None, *args, **kwargs) -> NoReturn:
+def raise_forbidden(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 403 Forbidden exception."""
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
-def raise_banned(detail: str = "此文件已被管理员封禁,仅允许删除操作", *args, **kwargs) -> NoReturn:
+def raise_banned(detail: str = "此文件已被管理员封禁,仅允许删除操作") -> NoReturn:
"""Raises an HTTP 403 Forbidden exception for banned objects."""
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
-def raise_not_found(detail: str | None = None, *args, **kwargs) -> NoReturn:
+def raise_not_found(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 404 Not Found exception."""
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
-def raise_conflict(*args, **kwargs) -> NoReturn:
+def raise_conflict(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 409 Conflict exception."""
- raise HTTPException(status_code=status.HTTP_409_CONFLICT, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
-def raise_precondition_required(*args, **kwargs) -> NoReturn:
+def raise_precondition_required(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 428 Precondition required exception."""
- raise HTTPException(status_code=status.HTTP_428_PRECONDITION_REQUIRED, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_428_PRECONDITION_REQUIRED, detail=detail)
-def raise_too_many_requests(*args, **kwargs) -> NoReturn:
+def raise_too_many_requests(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 429 Too Many Requests exception."""
- raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=detail)
# --- 500 ---
-def raise_internal_error(detail: str = "服务器出现故障,请稍后再试或联系管理员", *args, **kwargs) -> NoReturn:
+def raise_internal_error(detail: str = "服务器出现故障,请稍后再试或联系管理员") -> NoReturn:
"""Raises an HTTP 500 Internal Server Error exception."""
- raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail)
-def raise_not_implemented(detail: str = "尚未支持这种方法", *args, **kwargs) -> NoReturn:
+def raise_not_implemented(detail: str = "尚未支持这种方法") -> NoReturn:
"""Raises an HTTP 501 Not Implemented exception."""
- raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=detail, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=detail)
-def raise_service_unavailable(*args, **kwargs) -> NoReturn:
+def raise_service_unavailable(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 503 Service Unavailable exception."""
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
-def raise_gateway_timeout(*args, **kwargs) -> NoReturn:
+def raise_gateway_timeout(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 504 Gateway Timeout exception."""
- raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, *args, **kwargs)
+ raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail=detail)
diff --git a/utils/password/pwd.py b/utils/password/pwd.py
index 9b5a33d..a428264 100644
--- a/utils/password/pwd.py
+++ b/utils/password/pwd.py
@@ -3,13 +3,13 @@ from typing import Literal
from loguru import logger
from argon2 import PasswordHasher
-from argon2.exceptions import VerifyMismatchError
+from argon2.exceptions import VerifyMismatchError, VerificationError
from enum import StrEnum
import pyotp
from itsdangerous import URLSafeTimedSerializer
from pydantic import BaseModel, Field
-from utils.JWT import SECRET_KEY
+from utils import JWT
from utils.conf import appmeta
# FIRST RECOMMENDED option per RFC 9106.
@@ -57,7 +57,7 @@ class TwoFactorResponse(TwoFactorBase):
class TwoFactorVerifyRequest(TwoFactorBase):
"""两步验证-验证请求 DTO"""
- code: int = Field(..., ge=100000, le=999999)
+ code: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
"""6 位验证码"""
class Password:
@@ -126,6 +126,9 @@ class Password:
return PasswordStatus.VALID
except VerifyMismatchError:
return PasswordStatus.INVALID
+ except VerificationError:
+ logger.warning("密码哈希格式无效,无法解码,可能需要删库重建。")
+ return PasswordStatus.INVALID
@staticmethod
async def generate_totp(
@@ -138,7 +141,7 @@ class Password:
:return: 包含 TOTP 密钥和 URI 的元组
"""
- serializer = URLSafeTimedSerializer(SECRET_KEY)
+ serializer = URLSafeTimedSerializer(JWT.SECRET_KEY)
secret = pyotp.random_base32()
@@ -159,7 +162,7 @@ class Password:
@staticmethod
def verify_totp(
secret: str,
- code: int,
+ code: str,
*args, **kwargs
) -> PasswordStatus:
"""
@@ -173,7 +176,7 @@ class Password:
:return: 验证是否成功
"""
totp = pyotp.TOTP(secret)
- if totp.verify(otp=str(code), *args, **kwargs):
+ if totp.verify(otp=code, *args, **kwargs):
return PasswordStatus.VALID
else:
return PasswordStatus.INVALID
\ No newline at end of file
diff --git a/uv.lock b/uv.lock
index ca54b67..21da638 100644
--- a/uv.lock
+++ b/uv.lock
@@ -245,6 +245,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" },
]
+[[package]]
+name = "captcha"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b4/65/8e186bb798f33ba390eab897c995b0fcee92bc030e0f40cb8ea01f34dd07/captcha-0.7.1.tar.gz", hash = "sha256:a1b462bcc633a64d8db5efa7754548a877c698d98f87716c620a707364cabd6b", size = 226561, upload-time = "2025-03-01T05:00:13.395Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/ff/3f0982ecd37c2d6a7266c22e7ea2e47d0773fe449984184c5316459d2776/captcha-0.7.1-py3-none-any.whl", hash = "sha256:8b73b5aba841ad1e5bdb856205bf5f09560b728ee890eb9dae42901219c8c599", size = 147606, upload-time = "2025-03-01T05:00:10.433Z" },
+]
+
[[package]]
name = "cbor2"
version = "5.7.1"
@@ -473,6 +485,7 @@ dependencies = [
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "cachetools" },
+ { name = "captcha" },
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "itsdangerous" },
@@ -500,6 +513,7 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "cachetools", specifier = ">=6.2.4" },
+ { name = "captcha", specifier = ">=0.7.1" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.122.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "itsdangerous", specifier = ">=2.2.0" },
@@ -1094,6 +1108,64 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
+[[package]]
+name = "pillow"
+version = "12.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
+ { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
+ { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
+ { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
+ { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
+ { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
+ { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
+ { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
+ { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
+ { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
+ { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
+ { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
+ { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
+ { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
+ { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
+ { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
+ { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
+]
+
[[package]]
name = "pluggy"
version = "1.6.0"