diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b9c127d --- /dev/null +++ b/.editorconfig @@ -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 \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py index f7a359c..abca687 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -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 diff --git a/models/file.py b/models/file.py deleted file mode 100644 index 0c815a8..0000000 --- a/models/file.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/models/folder.py b/models/folder.py deleted file mode 100644 index 09f1cbd..0000000 --- a/models/folder.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/models/migration.py b/models/migration.py index 4ff39b7..e9eb8bf 100644 --- a/models/migration.py +++ b/models/migration.py @@ -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}') diff --git a/models/object.py b/models/object.py new file mode 100644 index 0000000..e35d2e6 --- /dev/null +++ b/models/object.py @@ -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) + """父目录ID,NULL 表示这是用户的根目录""" + + 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" + ) diff --git a/models/policy.py b/models/policy.py index 231e3c3..dabbd67 100644 --- a/models/policy.py +++ b/models/policy.py @@ -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( diff --git a/models/response.py b/models/response.py index 5227ae3..ca575a7 100644 --- a/models/response.py +++ b/models/response.py @@ -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="存储策略") \ No newline at end of file diff --git a/models/share.py b/models/share.py index 120193c..2d45f1c 100644 --- a/models/share.py +++ b/models/share.py @@ -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") \ No newline at end of file + """分享创建者""" + + 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 diff --git a/models/source_link.py b/models/source_link.py index 989ccc4..b3eaac9 100644 --- a/models/source_link.py +++ b/models/source_link.py @@ -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") \ No newline at end of file + object: "Object" = Relationship(back_populates="source_links") + """关联的对象""" \ No newline at end of file diff --git a/models/user.py b/models/user.py index e7a0fbb..ec02e58 100644 --- a/models/user.py +++ b/models/user.py @@ -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") diff --git a/routers/controllers/directory.py b/routers/controllers/directory.py index 5683bf3..4caed14 100644 --- a/routers/controllers/directory.py +++ b/routers/controllers/directory.py @@ -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=[], + ) ) - ) \ No newline at end of file + ) + + +@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}", + } + )