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

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