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

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