feat: add theme preset system with admin CRUD, public listing, and user theme settings

- Add ChromaticColor (17 Tailwind colors) and NeutralColor (5 grays) enums
- Add ThemePreset table with flat color columns and unique name constraint
- Add admin theme endpoints (CRUD + set default) at /api/v1/admin/theme
- Add public theme listing at /api/v1/site/themes
- Add user theme settings (PATCH /theme) with color snapshot on User model
- User.color_* columns store per-user overrides; fallback to default preset then builtin
- Initialize default theme preset in migration
- Remove legacy defaultTheme/themes settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 19:34:41 +08:00
parent a99091ea7a
commit 4c1b7a8aad
29 changed files with 1832 additions and 404 deletions

View File

@@ -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),主要包括:

View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View File

@@ -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:
"""
删除分享。

View File

@@ -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}' 设为默认")

View File

@@ -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(

View File

@@ -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,
}
)

View File

@@ -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="同名文件已存在")

View File

@@ -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:

View File

@@ -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='/',

View File

@@ -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='站点全局配置',

View File

@@ -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. 硬删除根 ObjectCASCADE 清理子对象)
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} 个对象")

View File

@@ -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_tokenGET /2fa 返回的令牌)和 code6 位验证码)。
"""
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"}
)
user = await user.save(session)

View File

@@ -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)
)

View File

@@ -0,0 +1,5 @@
from captcha.image import ImageCaptcha
captcha = ImageCaptcha()
print(captcha.generate())

View File

@@ -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,
)

491
service/storage/object.py Normal file
View File

@@ -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 查询,不触发 commitORM 对象始终有效。
: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 时物理删除文件,
最后硬删除根 ObjectCASCADE 自动清理子对象)。
: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 查询,不触发 commitORM 对象始终有效。
: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)

View File

@@ -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

View File

@@ -1,7 +1,71 @@
from enum import StrEnum
from .base import SQLModelBase
class ThemeResponse(SQLModelBase):
"""主题响应 DTO"""
pass
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):
"""嵌套颜色 DTOAPI 请求/响应层使用"""
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,
)

File diff suppressed because one or more lines are too long

View File

@@ -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列表"""

View File

@@ -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"

View File

@@ -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

117
sqlmodels/theme_preset.py Normal file
View File

@@ -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]
"""主题预设列表"""

View File

@@ -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)
"""语言偏好"""

View File

@@ -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)

View File

@@ -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

72
uv.lock generated
View File

@@ -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"