feat: 合并 File 和 Folder 模型为统一的 Object 模型,优化对象管理逻辑

refactor: 更新相关模型和路由以支持新对象模型,移除冗余代码
This commit is contained in:
2025-12-18 17:18:40 +08:00
parent d271c81de7
commit 68343c710b
12 changed files with 420 additions and 153 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = crlf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

View File

@@ -4,8 +4,7 @@ from .user import User
from .user_authn import UserAuthn
from .download import Download
from .file import File
from .folder import Folder
from .object import Object, ObjectType
from .group import Group
from .node import Node
from .order import Order

View File

@@ -1,36 +0,0 @@
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint, Index
from .base import TableBase
if TYPE_CHECKING:
from .user import User
from .folder import Folder
from .policy import Policy
from .source_link import SourceLink
class File(TableBase, table=True):
__table_args__ = (
UniqueConstraint("folder_id", "name", name="uq_file_folder_name_active"),
CheckConstraint("name NOT LIKE '%/%' AND name NOT LIKE '%\\%'", name="ck_file_name_no_slash"),
Index("ix_file_user_updated", "user_id", "updated_at"),
Index("ix_file_folder_updated", "folder_id", "updated_at"),
Index("ix_file_user_size", "user_id", "size"),
)
name: str = Field(max_length=255, description="文件名")
source_name: str | None = Field(default=None, description="源文件名")
size: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, description="文件大小(字节)")
upload_session_id: str | None = Field(default=None, max_length=255, unique=True, index=True, description="分块上传会话ID")
file_metadata: str | None = Field(default=None, description="文件元数据 (JSON格式)") # 后续可以考虑模型继承?
# 外键
user_id: int = Field(foreign_key="user.id", index=True, description="所属用户ID")
folder_id: int = Field(foreign_key="folder.id", index=True, description="所在目录ID")
policy_id: int = Field(foreign_key="policy.id", index=True, description="所属存储策略ID")
# 关系
user: "User" = Relationship(back_populates="files")
folder: "Folder" = Relationship(back_populates="files")
policy: "Policy" = Relationship(back_populates="files")
source_links: list["SourceLink"] = Relationship(back_populates="file")

View File

@@ -1,41 +0,0 @@
from typing import Optional, List, TYPE_CHECKING
from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint
from .base import TableBase
from datetime import datetime
if TYPE_CHECKING:
from .user import User
from .policy import Policy
from .file import File
class Folder(TableBase, table=True):
__table_args__ = (
UniqueConstraint(
"owner_id",
"parent_id",
"name",
name="uq_folder_name_parent",
),
CheckConstraint(
"name NOT LIKE '%/%' AND name NOT LIKE '%\\%'",
name="ck_folder_name_no_slash",
),
)
name: str = Field(max_length=255, nullable=False, description="目录名")
# 外键
parent_id: int | None = Field(default=None, foreign_key="folder.id", index=True, description="父目录ID")
owner_id: int = Field(foreign_key="user.id", index=True, description="所有者用户ID")
policy_id: int = Field(foreign_key="policy.id", index=True, description="所属存储策略ID")
# 关系
owner: "User" = Relationship(back_populates="folders")
policy: "Policy" = Relationship(back_populates="folders")
# 自我引用关系
parent: Optional["Folder"] = Relationship(back_populates="children", sa_relationship_kwargs={"remote_side": "Folder.id"})
children: List["Folder"] = Relationship(back_populates="parent")
files: List["File"] = Relationship(back_populates="folder")

View File

@@ -192,6 +192,7 @@ async def init_default_group() -> None:
async def init_default_user() -> None:
from .user import User
from .group import Group
from .object import Object, ObjectType
from .database import get_session
log.info('初始化管理员用户...')
@@ -210,7 +211,7 @@ async def init_default_user() -> None:
admin_password = Password.generate(8)
hashed_admin_password = Password.hash(admin_password)
await User(
admin_user = await User(
username="admin",
nick="admin",
status=True,
@@ -218,6 +219,15 @@ async def init_default_user() -> None:
password=hashed_admin_password,
).save(session)
# 为管理员创建根目录(使用默认存储策略)
await Object(
name="~",
type=ObjectType.FOLDER,
owner_id=admin_user.id,
parent_id=None,
policy_id=1, # 默认本地存储策略
).save(session)
log.info(f'初始管理员账号: admin')
log.info(f'初始管理员密码: {admin_password}')

185
models/object.py Normal file
View File

@@ -0,0 +1,185 @@
from typing import TYPE_CHECKING, Optional
from enum import StrEnum
from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint, Index
from .base import TableBase
if TYPE_CHECKING:
from .user import User
from .policy import Policy
from .source_link import SourceLink
from .share import Share
class ObjectType(StrEnum):
"""对象类型枚举"""
FILE = "file"
FOLDER = "folder"
class Object(TableBase, table=True):
"""
统一对象模型
合并了原有的 File 和 Folder 模型,通过 type 字段区分文件和目录。
根目录规则:
- 每个用户有一个显式根目录对象name="~", parent_id=NULL
- 用户创建的文件/文件夹的 parent_id 指向根目录或其他文件夹的 id
- 根目录的 policy_id 指定用户默认存储策略
"""
__table_args__ = (
# 同一父目录下名称唯一(包括 parent_id 为 NULL 的情况)
UniqueConstraint("owner_id", "parent_id", "name", name="uq_object_parent_name"),
# 名称不能包含斜杠
CheckConstraint(
"name NOT LIKE '%/%' AND name NOT LIKE '%\\%'",
name="ck_object_name_no_slash",
),
# 性能索引
Index("ix_object_owner_updated", "owner_id", "updated_at"),
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"),
)
# ==================== 基础字段 ====================
name: str = Field(max_length=255)
"""对象名称(文件名或目录名)"""
type: ObjectType
"""对象类型file 或 folder"""
# ==================== 文件专属字段 ====================
source_name: str | None = None
"""源文件名(仅文件有效)"""
size: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
"""文件大小(字节),目录为 0"""
upload_session_id: str | None = Field(default=None, max_length=255, unique=True, index=True)
"""分块上传会话ID仅文件有效"""
file_metadata: str | None = None
"""文件元数据 (JSON格式),仅文件有效"""
# ==================== 外键 ====================
parent_id: int | None = Field(default=None, foreign_key="object.id", index=True)
"""父目录IDNULL 表示这是用户的根目录"""
owner_id: int = Field(foreign_key="user.id", index=True)
"""所有者用户ID"""
policy_id: int = Field(foreign_key="policy.id", index=True)
"""存储策略ID文件直接使用目录作为子文件的默认策略"""
# ==================== 关系 ====================
owner: "User" = Relationship(back_populates="objects")
"""所有者"""
policy: "Policy" = Relationship(back_populates="objects")
"""存储策略"""
# 自引用关系
parent: Optional["Object"] = Relationship(
back_populates="children",
sa_relationship_kwargs={"remote_side": "Object.id"},
)
"""父目录"""
children: list["Object"] = Relationship(back_populates="parent")
"""子对象(文件和子目录)"""
# 仅文件有效的关系
source_links: list["SourceLink"] = Relationship(back_populates="object")
"""源链接列表(仅文件有效)"""
shares: list["Share"] = Relationship(back_populates="object")
"""分享列表"""
# ==================== 业务属性 ====================
@property
def is_file(self) -> bool:
"""是否为文件"""
return self.type == ObjectType.FILE
@property
def is_folder(self) -> bool:
"""是否为目录"""
return self.type == ObjectType.FOLDER
# ==================== 业务方法 ====================
@classmethod
async def get_root(cls, session, user_id: int) -> "Object | None":
"""
获取用户的根目录
:param session: 数据库会话
:param user_id: 用户ID
:return: 根目录对象,不存在则返回 None
"""
return await cls.get(
session,
(cls.owner_id == user_id) & (cls.parent_id == None)
)
@classmethod
async def get_by_path(cls, session, user_id: int, path: str) -> "Object | None":
"""
根据路径获取对象
:param session: 数据库会话
:param user_id: 用户ID
:param path: 路径,如 "/""/docs/images"
:return: Object 或 None
"""
path = path.strip()
if not path or path == "/" or path == "~":
return await cls.get_root(session, user_id)
# 移除开头的斜杠并分割路径
if path.startswith("/"):
path = path[1:]
parts = [p for p in path.split("/") if p]
if not parts:
return await cls.get_root(session, user_id)
# 从根目录开始遍历
current = await cls.get_root(session, user_id)
for part in parts:
if not current:
return None
current = await cls.get(
session,
(cls.owner_id == user_id) &
(cls.parent_id == current.id) &
(cls.name == part)
)
return current
@classmethod
async def get_children(cls, session, user_id: int, parent_id: int) -> list["Object"]:
"""
获取目录下的所有子对象
:param session: 数据库会话
:param user_id: 用户ID
:param parent_id: 父目录ID
:return: 子对象列表
"""
return await cls.get(
session,
(cls.owner_id == user_id) & (cls.parent_id == parent_id),
fetch_mode="all"
)

View File

@@ -1,12 +1,11 @@
from typing import Optional, List, TYPE_CHECKING
from typing import Optional, TYPE_CHECKING
from sqlmodel import Field, Relationship, text
from .base import TableBase
from enum import StrEnum
if TYPE_CHECKING:
from .file import File
from .folder import Folder
from .object import Object
class PolicyType(StrEnum):
LOCAL = "local"
@@ -58,8 +57,8 @@ class Policy(TableBase, table=True):
# options 示例: {"token":"","file_type":null,"mimetype":"","od_redirect":"http://127.0.0.1:8000/...","chunk_size":52428800,"s3_path_style":false}
# 关系
files: List["File"] = Relationship(back_populates="policy")
folders: List["Folder"] = Relationship(back_populates="policy")
objects: list["Object"] = Relationship(back_populates="policy")
"""策略下的所有对象"""
@staticmethod
async def create(

View File

@@ -132,6 +132,9 @@ class DirectoryModel(BaseModel):
'''
目录模型
'''
parent: str = Field(default=..., description="父目录ID")
parent: str | None
"""父目录ID根目录为None"""
objects: list[ObjectModel] = Field(default_factory=list, description="目录下的对象列表")
policy: PolicyModel = Field(default_factory=PolicyModel, description="存储策略")

View File

@@ -1,42 +1,71 @@
from typing import Optional, TYPE_CHECKING
from typing import TYPE_CHECKING
from datetime import datetime
from sqlmodel import Field, Relationship, text, CheckConstraint, UniqueConstraint, Index
from sqlmodel import Field, Relationship, text, UniqueConstraint, Index
from .base import TableBase
from datetime import datetime
if TYPE_CHECKING:
from .user import User
from .report import Report
from .object import Object
class Share(TableBase, table=True):
"""分享模型"""
__table_args__ = (
UniqueConstraint("code", name="uq_share_code"),
CheckConstraint("(file_id IS NOT NULL) <> (folder_id IS NOT NULL)", name="ck_share_xor"),
Index("ix_share_source_name", "source_name"),
Index("ix_share_user_created", "user_id", "created_at"),
Index("ix_share_object", "object_id"),
)
code: str = Field(max_length=64, nullable=False, index=True, description="分享码")
password: str | None = Field(default=None, max_length=255, description="分享密码(加密后)")
is_dir: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")}, description="是否为目录分享")
file_id: int | None = Field(default=None, foreign_key="file.id", index=True, description="文件ID二选一")
folder_id: int | None = Field(default=None, foreign_key="folder.id", index=True, description="目录ID二选一")
views: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, description="浏览次数")
downloads: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, description="下载次数")
remain_downloads: int | None = Field(default=None, description="剩余下载次数 (NULL为不限制)")
expires: datetime | None = Field(default=None, description="过期时间 (NULL为永不过期)")
preview_enabled: bool = Field(default=True, sa_column_kwargs={"server_default": text("true")}, description="是否允许预览")
source_name: str | None = Field(default=None, max_length=255, description="源名称(冗余字段,便于展示)")
score: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, description="兑换此分享所需的积分")
code: str = Field(max_length=64, nullable=False, index=True)
"""分享"""
password: str | None = Field(default=None, max_length=255)
"""分享密码(加密后"""
object_id: int = Field(foreign_key="object.id", index=True)
"""关联的对象ID"""
views: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
"""浏览次数"""
downloads: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
"""下载次数"""
remain_downloads: int | None = None
"""剩余下载次数 (NULL为不限制)"""
expires: datetime | None = None
"""过期时间 (NULL为永不过期)"""
preview_enabled: bool = Field(default=True, sa_column_kwargs={"server_default": text("true")})
"""是否允许预览"""
source_name: str | None = Field(default=None, max_length=255)
"""源名称(冗余字段,便于展示)"""
score: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
"""兑换此分享所需的积分"""
# 外键
user_id: int = Field(foreign_key="user.id", index=True, description="创建分享的用户ID")
user_id: int = Field(foreign_key="user.id", index=True)
"""创建分享的用户ID"""
# 关系
user: "User" = Relationship(back_populates="shares")
reports: list["Report"] = Relationship(back_populates="share")
"""分享创建者"""
object: "Object" = Relationship(back_populates="shares")
"""关联的对象"""
reports: list["Report"] = Relationship(back_populates="share")
"""举报列表"""
@property
def is_dir(self) -> bool:
"""是否为目录分享(向后兼容属性)"""
from .object import ObjectType
return self.object.type == ObjectType.FOLDER if self.object else False

View File

@@ -1,24 +1,29 @@
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, Index
from .base import TableBase
from datetime import datetime
if TYPE_CHECKING:
from .file import File
from .object import Object
class SourceLink(TableBase, table=True):
"""链接模型"""
__table_args__ = (
Index("ix_sourcelink_file_name", "file_id", "name"),
Index("ix_sourcelink_object_name", "object_id", "name"),
)
name: str = Field(max_length=255, description="链接名称")
downloads: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, description="通过此链接的下载次数")
name: str = Field(max_length=255)
"""链接名称"""
downloads: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
"""通过此链接的下载次数"""
# 外键
file_id: int = Field(foreign_key="file.id", index=True, description="关联的文件ID")
object_id: int = Field(foreign_key="object.id", index=True)
"""关联的对象ID必须是文件类型"""
# 关系
file: "File" = Relationship(back_populates="source_links")
object: "Object" = Relationship(back_populates="source_links")
"""关联的对象"""

View File

@@ -9,8 +9,7 @@ from .base import TableBase, SQLModelBase
if TYPE_CHECKING:
from .group import Group
from .download import Download
from .file import File
from .folder import Folder
from .object import Object
from .order import Order
from .share import Share
from .storage_pack import StoragePack
@@ -125,8 +124,8 @@ class User(TableBase, table=True):
)
downloads: list["Download"] = Relationship(back_populates="user")
files: list["File"] = Relationship(back_populates="user")
folders: list["Folder"] = Relationship(back_populates="owner")
objects: list["Object"] = Relationship(back_populates="owner")
"""用户的所有对象(文件和目录)"""
orders: list["Order"] = Relationship(back_populates="user")
shares: list["Share"] = Relationship(back_populates="user")
storage_packs: list["StoragePack"] = Relationship(back_populates="user")

View File

@@ -1,45 +1,148 @@
from fastapi import APIRouter, Depends
from middleware.auth import SignRequired
from models import response
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from middleware.auth import AuthRequired
from middleware.dependencies import SessionDep
from models import Object, ObjectType, User, response
directory_router = APIRouter(
prefix="/directory",
tags=["directory"]
)
@directory_router.put(
path='/',
summary='创建目录',
description='Create a directory endpoint.',
dependencies=[Depends(SignRequired)]
)
def router_directory_create() -> response.ResponseModel:
"""
Create a directory endpoint.
Returns:
ResponseModel: A model containing the response data for the directory creation.
"""
pass
class DirectoryCreateRequest(BaseModel):
"""创建目录请求"""
path: str
"""目录路径,如 /docs/images"""
policy_id: int | None = None
"""存储策略ID不指定则继承父目录"""
@directory_router.get(
path='/{path:path}',
summary='获取目录内容',
description='Get directory contents endpoint.',
dependencies=[Depends(SignRequired)]
path="/{path:path}",
summary="获取目录内容",
)
def router_directory_get(path: str) -> response.ResponseModel:
async def router_directory_get(
session: SessionDep,
user: Annotated[User, Depends(AuthRequired)],
path: str = ""
) -> response.ResponseModel:
"""
Get directory contents endpoint.
Args:
path (str): The path of the directory to retrieve contents from.
Returns:
ResponseModel: A model containing the response data for the directory contents.
获取目录内容
:param session: 数据库会话
:param user: 当前登录用户
:param path: 目录路径,空或 "/" 表示根目录
:return: 目录内容
"""
folder = await Object.get_by_path(session, user.id, path or "/")
if not folder:
raise HTTPException(status_code=404, detail="目录不存在")
if not folder.is_folder:
raise HTTPException(status_code=400, detail="指定路径不是目录")
children = await Object.get_children(session, user.id, folder.id)
policy = await folder.awaitable_attrs.policy
objects = [
response.ObjectModel(
id=str(child.id),
name=child.name,
path=f"/{child.name}", # TODO: 完整路径
thumb=False,
size=child.size,
type="folder" if child.is_folder else "file",
date=child.updated_at,
create_date=child.created_at,
source_enabled=False,
)
for child in children
]
return response.ResponseModel(
data=response.DirectoryModel(
parent=str(folder.parent_id) if folder.parent_id else None,
objects=objects,
policy=response.PolicyModel(
id=str(policy.id),
name=policy.name,
type=policy.type.value,
max_size=policy.max_size,
file_type=[],
)
)
)
)
@directory_router.put(
path="/",
summary="创建目录",
)
async def router_directory_create(
session: SessionDep,
user: Annotated[User, Depends(AuthRequired)],
request: DirectoryCreateRequest
) -> response.ResponseModel:
"""
创建目录
:param session: 数据库会话
:param user: 当前登录用户
:param request: 创建请求
:return: 创建结果
"""
path = request.path.strip()
if not path or path == "/":
raise HTTPException(status_code=400, detail="路径不能为空或根目录")
# 解析路径
if path.startswith("/"):
path = path[1:]
parts = [p for p in path.split("/") if p]
if not parts:
raise HTTPException(status_code=400, detail="无效的目录路径")
new_folder_name = parts[-1]
parent_path = "/" + "/".join(parts[:-1]) if len(parts) > 1 else "/"
parent = await Object.get_by_path(session, user.id, parent_path)
if not parent:
raise HTTPException(status_code=404, detail="父目录不存在")
if not parent.is_folder:
raise HTTPException(status_code=400, detail="父路径不是目录")
# 检查是否已存在同名对象
existing = await Object.get(
session,
(Object.owner_id == user.id) &
(Object.parent_id == parent.id) &
(Object.name == new_folder_name)
)
if existing:
raise HTTPException(status_code=409, detail="同名文件或目录已存在")
policy_id = request.policy_id if request.policy_id else parent.policy_id
new_folder = await Object(
name=new_folder_name,
type=ObjectType.FOLDER,
owner_id=user.id,
parent_id=parent.id,
policy_id=policy_id,
).save(session)
return response.ResponseModel(
data={
"id": new_folder.id,
"name": new_folder.name,
"path": f"{parent_path.rstrip('/')}/{new_folder_name}",
}
)