from datetime import datetime from typing import TYPE_CHECKING, Literal from uuid import UUID from enum import StrEnum from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint, Index, text from .base import SQLModelBase from .mixin import UUIDTableBaseMixin if TYPE_CHECKING: from .user import User from .policy import Policy from .source_link import SourceLink from .share import Share from .physical_file import PhysicalFile class ObjectType(StrEnum): """对象类型枚举""" FILE = "file" FOLDER = "folder" class StorageType(StrEnum): """存储类型枚举""" LOCAL = "local" QINIU = "qiniu" TENCENT = "tencent" ALIYUN = "aliyun" ONEDRIVE = "onedrive" GOOGLE_DRIVE = "google_drive" DROPBOX = "dropbox" WEBDAV = "webdav" REMOTE = "remote" class FileMetadataBase(SQLModelBase): """文件元数据基础模型""" width: int | None = Field(default=None) """图片宽度(像素)""" height: int | None = Field(default=None) """图片高度(像素)""" duration: float | None = Field(default=None) """音视频时长(秒)""" bitrate: int | None = Field(default=None) """比特率(kbps)""" mime_type: str | None = Field(default=None, max_length=127) """MIME类型""" checksum_md5: str | None = Field(default=None, max_length=32) """MD5校验和""" checksum_sha256: str | None = Field(default=None, max_length=64) """SHA256校验和""" # ==================== Base 模型 ==================== class ObjectBase(SQLModelBase): """对象基础字段,供数据库模型和 DTO 共享""" name: str """对象名称(文件名或目录名)""" type: ObjectType """对象类型""" size: int | None = None """文件大小(字节),目录为 None""" # ==================== DTO 模型 ==================== class DirectoryCreateRequest(SQLModelBase): """创建目录请求 DTO""" parent_id: UUID """父目录UUID""" name: str """目录名称""" policy_id: UUID | None = None """存储策略UUID,不指定则继承父目录""" class ObjectMoveRequest(SQLModelBase): """移动对象请求 DTO""" src_ids: UUID | list[UUID] """源对象UUID列表""" dst_id: UUID """目标文件夹UUID""" class ObjectDeleteRequest(SQLModelBase): """删除对象请求 DTO""" ids: UUID | list[UUID] """待删除对象UUID列表""" class ObjectResponse(ObjectBase): """对象响应 DTO""" id: UUID """对象UUID""" thumb: bool = False """是否有缩略图""" date: datetime """对象修改时间""" create_date: datetime """对象创建时间""" source_enabled: bool = False """是否启用离线下载源""" class PolicyResponse(SQLModelBase): """存储策略响应 DTO""" id: UUID """策略UUID""" name: str """策略名称""" type: StorageType """存储类型""" max_size: int = Field(ge=0, default=0) """单文件最大限制,单位字节,0表示不限制""" file_type: list[str] | None = None """允许的文件类型列表,None 表示不限制""" class DirectoryResponse(SQLModelBase): """目录响应 DTO""" id: UUID """当前目录UUID""" parent: UUID | None = None """父目录UUID,根目录为None""" objects: list[ObjectResponse] = [] """目录下的对象列表""" policy: PolicyResponse """存储策略""" # ==================== 数据库模型 ==================== class FileMetadata(FileMetadataBase, UUIDTableBaseMixin): """文件元数据模型(与Object一对一关联)""" object_id: UUID = Field( foreign_key="object.id", unique=True, index=True, ondelete="CASCADE" ) """关联的对象UUID""" # 反向关系 object: "Object" = Relationship(back_populates="file_metadata") """关联的对象""" class Object(ObjectBase, UUIDTableBaseMixin): """ 统一对象模型 合并了原有的 File 和 Folder 模型,通过 type 字段区分文件和目录。 根目录规则: - 每个用户有一个显式根目录对象(name=用户的username, parent_id=NULL) - 用户创建的文件/文件夹的 parent_id 指向根目录或其他文件夹的 id - 根目录的 policy_id 指定用户默认存储策略 - 路径格式:/username/path/to/file(如 /admin/docs/readme.md) """ __table_args__ = ( # 同一父目录下名称唯一(包括 parent_id 为 NULL 的情况) UniqueConstraint("owner_id", "parent_id", "name", name="uq_object_parent_name"), # 名称不能包含斜杠 ([TODO] 还有特殊字符) 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""" password: str | None = Field(default=None, max_length=255) """对象独立密码(仅当用户为对象单独设置密码时有效)""" # ==================== 文件专属字段 ==================== 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(仅文件有效)""" physical_file_id: UUID | None = Field( default=None, foreign_key="physicalfile.id", index=True, ondelete="SET NULL" ) """关联的物理文件UUID(仅文件有效,目录为NULL)""" # ==================== 外键 ==================== parent_id: UUID | None = Field( default=None, foreign_key="object.id", index=True, ondelete="CASCADE" ) """父目录UUID,NULL 表示这是用户的根目录""" owner_id: UUID = Field( foreign_key="user.id", index=True, ondelete="CASCADE" ) """所有者用户UUID""" policy_id: UUID = Field( foreign_key="policy.id", index=True, ondelete="RESTRICT" ) """存储策略UUID(文件直接使用,目录作为子文件的默认策略)""" # ==================== 封禁相关字段 ==================== is_banned: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")}) """是否被封禁""" banned_at: datetime | None = None """封禁时间""" banned_by: UUID | None = Field( default=None, foreign_key="user.id", index=True, ondelete="SET NULL", sa_column_kwargs={"name": "banned_by"} ) """封禁操作者UUID""" ban_reason: str | None = Field(default=None, max_length=500) """封禁原因""" # ==================== 关系 ==================== owner: "User" = Relationship( back_populates="objects", sa_relationship_kwargs={"foreign_keys": "[Object.owner_id]"} ) """所有者""" banner: "User" = Relationship( sa_relationship_kwargs={"foreign_keys": "[Object.banned_by]"} ) """封禁操作者""" policy: "Policy" = Relationship(back_populates="objects") """存储策略""" # 自引用关系 parent: "Object" = Relationship( back_populates="children", sa_relationship_kwargs={"remote_side": "Object.id"}, ) """父目录""" children: list["Object"] = Relationship( back_populates="parent", sa_relationship_kwargs={"cascade": "all, delete-orphan"} ) """子对象(文件和子目录)""" # 仅文件有效的关系 file_metadata: FileMetadata | None = Relationship( back_populates="object", sa_relationship_kwargs={"uselist": False, "cascade": "all, delete-orphan"}, ) """文件元数据(仅文件有效)""" source_links: list["SourceLink"] = Relationship( back_populates="object", sa_relationship_kwargs={"cascade": "all, delete-orphan"} ) """源链接列表(仅文件有效)""" shares: list["Share"] = Relationship( back_populates="object", sa_relationship_kwargs={"cascade": "all, delete-orphan"} ) """分享列表""" physical_file: "PhysicalFile" = Relationship(back_populates="objects") """关联的物理文件(仅文件有效)""" # ==================== 业务属性 ==================== @property def source_name(self) -> str | None: """ 源文件存储路径(向后兼容属性) :return: 物理文件存储路径,如果没有关联物理文件则返回 None """ if self.physical_file: return self.physical_file.storage_path return None @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: UUID) -> "Object | None": """ 获取用户的根目录 :param session: 数据库会话 :param user_id: 用户UUID :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: UUID, path: str, username: str, ) -> "Object | None": """ 根据路径获取对象 :param session: 数据库会话 :param user_id: 用户UUID :param path: 路径,如 "/username" 或 "/username/docs/images" :param username: 用户名,用于识别根目录 :return: Object 或 None """ path = path.strip() if not path: raise ValueError("路径不能为空") # 获取用户根目录 root = await cls.get_root(session, user_id) if not root: return None # 移除开头的斜杠并分割路径 if path.startswith("/"): path = path[1:] parts = [p for p in path.split("/") if p] # 空路径 -> 返回根目录 if not parts: return root # 检查第一部分是否是用户名(根目录名) if parts[0] == username: # 路径以用户名开头,如 /admin/docs if len(parts) == 1: # 只有用户名,返回根目录 return root # 去掉用户名部分,从第二个部分开始遍历 parts = parts[1:] # 从根目录开始遍历剩余路径 current = root 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: UUID, parent_id: UUID) -> list["Object"]: """ 获取目录下的所有子对象 :param session: 数据库会话 :param user_id: 用户UUID :param parent_id: 父目录UUID :return: 子对象列表 """ return await cls.get( session, (cls.owner_id == user_id) & (cls.parent_id == parent_id), fetch_mode="all" ) # ==================== 上传会话模型 ==================== class UploadSessionBase(SQLModelBase): """上传会话基础字段""" file_name: str = Field(max_length=255) """原始文件名""" file_size: int = Field(ge=0) """文件总大小(字节)""" chunk_size: int = Field(ge=1) """分片大小(字节)""" total_chunks: int = Field(ge=1) """总分片数""" class UploadSession(UploadSessionBase, UUIDTableBaseMixin): """ 上传会话模型 用于管理分片上传的会话状态。 会话有效期为24小时,过期后自动失效。 """ # 会话状态 uploaded_chunks: int = 0 """已上传分片数""" uploaded_size: int = 0 """已上传大小(字节)""" storage_path: str | None = Field(default=None, max_length=512) """文件存储路径""" expires_at: datetime """会话过期时间""" # 外键 owner_id: UUID = Field(foreign_key="user.id", index=True, ondelete="CASCADE") """上传者用户UUID""" parent_id: UUID = Field(foreign_key="object.id", index=True, ondelete="CASCADE") """目标父目录UUID""" policy_id: UUID = Field(foreign_key="policy.id", index=True, ondelete="RESTRICT") """存储策略UUID""" # 关系 owner: "User" = Relationship() """上传者""" parent: "Object" = Relationship( sa_relationship_kwargs={"foreign_keys": "[UploadSession.parent_id]"} ) """目标父目录""" policy: "Policy" = Relationship() """存储策略""" @property def is_expired(self) -> bool: """会话是否已过期""" return datetime.now() > self.expires_at @property def is_complete(self) -> bool: """上传是否完成""" return self.uploaded_chunks >= self.total_chunks # ==================== 上传会话相关 DTO ==================== class CreateUploadSessionRequest(SQLModelBase): """创建上传会话请求 DTO""" file_name: str = Field(max_length=255) """文件名""" file_size: int = Field(ge=0) """文件大小(字节)""" parent_id: UUID """父目录UUID""" policy_id: UUID | None = None """存储策略UUID,不指定则使用父目录的策略""" class UploadSessionResponse(SQLModelBase): """上传会话响应 DTO""" id: UUID """会话UUID""" file_name: str """原始文件名""" file_size: int """文件总大小(字节)""" chunk_size: int """分片大小(字节)""" total_chunks: int """总分片数""" uploaded_chunks: int """已上传分片数""" expires_at: datetime """过期时间""" class UploadChunkResponse(SQLModelBase): """上传分片响应 DTO""" uploaded_chunks: int """已上传分片数""" total_chunks: int """总分片数""" is_complete: bool """是否上传完成""" object_id: UUID | None = None """完成后的文件对象UUID,未完成时为None""" class CreateFileRequest(SQLModelBase): """创建空白文件请求 DTO""" name: str = Field(max_length=255) """文件名""" parent_id: UUID """父目录UUID""" policy_id: UUID | None = None """存储策略UUID,不指定则使用父目录的策略""" # ==================== 对象操作相关 DTO ==================== class ObjectCopyRequest(SQLModelBase): """复制对象请求 DTO""" src_ids: list[UUID] """源对象UUID列表""" dst_id: UUID """目标文件夹UUID""" class ObjectRenameRequest(SQLModelBase): """重命名对象请求 DTO""" id: UUID """对象UUID""" new_name: str = Field(max_length=255) """新名称""" class ObjectPropertyResponse(SQLModelBase): """对象基本属性响应 DTO""" id: UUID """对象UUID""" name: str """对象名称""" type: ObjectType """对象类型""" size: int """文件大小(字节)""" created_at: datetime """创建时间""" updated_at: datetime """修改时间""" parent_id: UUID | None """父目录UUID""" class ObjectPropertyDetailResponse(ObjectPropertyResponse): """对象详细属性响应 DTO(继承基本属性)""" # 元数据信息 mime_type: str | None = None """MIME类型""" width: int | None = None """图片宽度(像素)""" height: int | None = None """图片高度(像素)""" duration: float | None = None """音视频时长(秒)""" checksum_md5: str | None = None """MD5校验和""" # 分享统计 share_count: int = 0 """分享次数""" total_views: int = 0 """总浏览次数""" total_downloads: int = 0 """总下载次数""" # 存储信息 policy_name: str | None = None """存储策略名称""" reference_count: int = 1 """物理文件引用计数(仅文件有效)""" # ==================== 管理员文件管理 DTO ==================== class AdminFileResponse(ObjectResponse): """管理员文件响应 DTO""" owner_id: UUID """所有者UUID""" owner_username: str """所有者用户名""" policy_name: str """存储策略名称""" is_banned: bool = False """是否被封禁""" banned_at: datetime | None = None """封禁时间""" ban_reason: str | None = None """封禁原因""" @classmethod def from_object( cls, obj: "Object", owner: "User | None", policy: "Policy | None", ) -> "AdminFileResponse": """从 Object ORM 对象构建""" return cls( # ObjectBase 字段 **ObjectBase.model_validate(obj, from_attributes=True).model_dump(), # ObjectResponse 字段 id=obj.id, thumb=False, date=obj.updated_at, create_date=obj.created_at, source_enabled=False, # AdminFileResponse 字段 owner_id=obj.owner_id, owner_username=owner.username if owner else "unknown", policy_name=policy.name if policy else "unknown", is_banned=obj.is_banned, banned_at=obj.banned_at, ban_reason=obj.ban_reason, ) class FileBanRequest(SQLModelBase): """文件封禁请求 DTO""" is_banned: bool = True """是否封禁""" reason: str | None = Field(default=None, max_length=500) """封禁原因""" class AdminFileListResponse(SQLModelBase): """管理员文件列表响应 DTO""" files: list[AdminFileResponse] = [] """文件列表""" total: int = 0 """总数"""