You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

70 KiB

文件夹管理服务

文档版本:v3.8
最后更新:2026-02-02
变更:完成代码实现,修复 Model 层、补充 Repository 层、创建数据库迁移文件


目录

  1. 服务概述
  2. 核心功能
  3. 数据库设计
  4. 服务实现
  5. API 接口
  6. 权限管理

服务概述

文件夹管理服务负责处理文件夹的创建、查询、移动、删除等业务逻辑,支持树形结构和权限管理。

职责

  • 文件夹 CRUD 操作
  • 文件夹树形结构管理(最大 10 层)
  • 文件夹移动(防止循环引用)
  • 文件夹权限管理(支持继承)
  • 统计文件夹内项目数量
  • 文件夹封面图片管理
  • 文件夹分类管理(我的项目/协作项目)

文件夹层级结构

虚拟根目录(前端显示)
├── 我的项目(虚拟视图,folder_category=1)
│   ├── 用户创建的文件夹(一级,parent_folder_id=NULL, folder_category=1)
│   │   ├── 子文件夹(二级,自动继承 folder_category)
│   │   └── 父项目(parent_project_id=NULL)
│   │       └── 子项目(parent_project_id=父项目ID, screenplay_id=剧本ID)
│   └── 根项目(folder_id=NULL, parent_project_id=NULL)
└── 协作项目(虚拟视图,folder_category=2)
    ├── 用户创建的文件夹(一级,parent_folder_id=NULL, folder_category=2)
    │   └── 父项目
    │       └── 子项目
    └── 根项目

重要设计

  • "我的项目"和"协作项目"是前端虚拟视图,不是数据库记录
  • 用户创建的文件夹通过 folder_category 字段区分所属分类
  • 根文件夹(parent_folder_id = NULL)必须指定 folder_category
  • 子文件夹自动继承父文件夹的 folder_category
  • 父项目parent_project_id = NULL,可以属于文件夹或根目录
  • 子项目parent_project_id = 父项目ID,关联剧本(screenplay_id
  • 前端负责虚拟显示"我的项目"和"协作项目"作为顶层导航
  • 前端负责在文件夹树中展示父子项目关系

核心功能

1. 文件夹创建

  • 支持创建根文件夹(parent_folder_id = NULL)和子文件夹
  • 创建根文件夹时必须指定 folder_category(1=我的项目,2=协作项目)
  • 子文件夹自动继承父文件夹的 folder_category
  • 检查同级名称唯一性
  • 自动设置所有者
  • 支持自定义颜色、图标和封面图片
  • 自动计算路径和层级(最大 10 层)

2. 文件夹查询

  • 获取文件夹列表(支持分页)
  • 获取文件夹树形结构(支持最大深度限制)
  • 获取文件夹详情
  • 统计子文件夹和项目数量
  • 获取文件夹路径(面包屑导航)

3. 文件夹移动

  • 移动文件夹到其他文件夹
  • 防止循环引用(不能移动到自己的子文件夹)
  • 自动更新路径和层级
  • 权限检查(需要 editor 权限)

4. 文件夹删除

  • 软删除(标记 deleted_at
  • 级联处理子文件夹和项目(可选)
  • 权限检查(需要 owner 权限)

5. 权限管理

  • 支持三种角色:owner、editor、viewer
  • 支持权限继承(子文件夹继承父文件夹权限)
  • 支持文件夹成员管理
  • 项目继承文件夹权限

数据库设计

3.1 folders 表

CREATE TABLE folders (
    id UUID PRIMARY KEY,  -- UUID 由应用层生成
    name TEXT NOT NULL,
    description TEXT,
    parent_folder_id UUID,  -- 逻辑外键,无物理约束
    owner_id UUID NOT NULL,  -- 逻辑外键,无物理约束

    -- 路径信息(用于快速查询,由触发器自动计算)
    path TEXT NOT NULL DEFAULT '/',
    level INTEGER NOT NULL DEFAULT 0 CHECK (level >= 0 AND level <= 10),

    -- 文件夹分类(使用 SMALLINT 存储,代码层枚举,由触发器自动继承)
    folder_category SMALLINT NOT NULL DEFAULT 1 CHECK (folder_category IN (1, 2)),
    -- 1 = 我的项目 (MY_PROJECTS)
    -- 2 = 协作项目 (COLLABORATIVE_PROJECTS)
    -- 预留 3-255 用于未来扩展

    -- 排序
    sort_order INTEGER NOT NULL DEFAULT 0,

    -- 样式
    color TEXT, -- 十六进制颜色,如 #FF5733
    icon TEXT, -- 图标名称
    cover_image_id UUID,  -- 逻辑外键,无物理约束

    -- 共享
    is_shared BOOLEAN NOT NULL DEFAULT false,

    -- 时间戳(使用 TIMESTAMPTZ 记录事件时间)
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ
);

-- 表和字段注释
COMMENT ON TABLE folders IS '文件夹表';
COMMENT ON COLUMN folders.id IS '文件夹ID (UUID v7)';
COMMENT ON COLUMN folders.name IS '文件夹名称';
COMMENT ON COLUMN folders.description IS '文件夹描述';
COMMENT ON COLUMN folders.parent_folder_id IS '父文件夹ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folders.owner_id IS '所有者用户ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folders.path IS '文件夹完整路径(用于快速查询,格式:/父文件夹/子文件夹)';
COMMENT ON COLUMN folders.level IS '文件夹层级深度(0为根文件夹,最大10层)';
COMMENT ON COLUMN folders.folder_category IS '文件夹分类: 1=我的项目, 2=协作项目';
COMMENT ON COLUMN folders.sort_order IS '排序顺序';
COMMENT ON COLUMN folders.color IS '文件夹颜色(十六进制,如 #FF5733)';
COMMENT ON COLUMN folders.icon IS '文件夹图标名称';
COMMENT ON COLUMN folders.cover_image_id IS '封面图片ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folders.is_shared IS '是否共享';
COMMENT ON COLUMN folders.created_at IS '创建时间(自动记录时区)';
COMMENT ON COLUMN folders.updated_at IS '更新时间(自动记录时区)';
COMMENT ON COLUMN folders.deleted_at IS '软删除时间(自动记录时区)';

-- 索引
CREATE INDEX idx_folders_parent_folder_id ON folders (parent_folder_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_owner_id ON folders (owner_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_category_owner ON folders (folder_category, owner_id, parent_folder_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_path ON folders (path) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_level ON folders (level) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_sort_order ON folders (parent_folder_id, sort_order) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_is_shared ON folders (is_shared) WHERE deleted_at IS NULL AND is_shared = true;
CREATE INDEX idx_folders_cover_image_id ON folders (cover_image_id) WHERE cover_image_id IS NOT NULL;
-- 全文搜索索引
CREATE INDEX idx_folders_name_trgm ON folders USING GIN (name gin_trgm_ops) WHERE deleted_at IS NULL;

-- 唯一性约束:使用部分唯一索引处理 NULL 值
-- 非根文件夹:parent_folder_id 不为 NULL
CREATE UNIQUE INDEX idx_folders_name_unique_with_parent 
ON folders (parent_folder_id, owner_id, name) 
WHERE parent_folder_id IS NOT NULL AND deleted_at IS NULL;

-- 根文件夹:parent_folder_id 为 NULL
CREATE UNIQUE INDEX idx_folders_name_unique_root 
ON folders (owner_id, name) 
WHERE parent_folder_id IS NULL AND deleted_at IS NULL;

-- 触发器:自动更新 updated_at
CREATE TRIGGER update_folders_updated_at
    BEFORE UPDATE ON folders
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

-- 触发器:自动计算路径和层级
CREATE OR REPLACE FUNCTION update_folder_path()
RETURNS TRIGGER AS $$
DECLARE
    parent_path TEXT;
    parent_level INTEGER;
BEGIN
    -- 如果是根文件夹(parent_folder_id = NULL)
    IF NEW.parent_folder_id IS NULL THEN
        NEW.path = '/' || NEW.name;
        NEW.level = 0;
    ELSE
        -- 获取父文件夹路径和层级
        SELECT f.path, f.level INTO parent_path, parent_level
        FROM folders f
        WHERE f.id = NEW.parent_folder_id;

        IF parent_path IS NULL THEN
            RAISE EXCEPTION '父文件夹不存在';
        END IF;

        NEW.path = parent_path || '/' || NEW.name;
        NEW.level = parent_level + 1;

        -- 检查层级限制
        IF NEW.level > 10 THEN
            RAISE EXCEPTION '文件夹层级不能超过10层';
        END IF;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER update_folder_path_trigger
    BEFORE INSERT OR UPDATE ON folders
    FOR EACH ROW
    EXECUTE FUNCTION update_folder_path();

-- 触发器:子文件夹自动继承父文件夹的 folder_category
CREATE OR REPLACE FUNCTION inherit_folder_category()
RETURNS TRIGGER AS $$
DECLARE
    parent_category SMALLINT;
BEGIN
    -- 如果是根文件夹,必须指定 folder_category
    IF NEW.parent_folder_id IS NULL THEN
        IF NEW.folder_category IS NULL THEN
            RAISE EXCEPTION '创建根文件夹时必须指定 folder_category';
        END IF;
    ELSE
        -- 子文件夹自动继承父文件夹的 folder_category
        SELECT folder_category INTO parent_category
        FROM folders
        WHERE id = NEW.parent_folder_id;
        
        IF parent_category IS NULL THEN
            RAISE EXCEPTION '父文件夹不存在';
        END IF;
        
        NEW.folder_category = parent_category;
    END IF;
    
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_inherit_folder_category
    BEFORE INSERT ON folders
    FOR EACH ROW
    EXECUTE FUNCTION inherit_folder_category();

设计说明

  • UUID 由应用层生成(通过 generate_uuid() 函数),不使用数据库默认值
  • pathlevel 字段由触发器自动计算,提升查询性能
  • folder_category 由触发器自动继承父文件夹分类
  • 名称唯一性:使用部分唯一索引处理 NULL 值,配合应用层主动检查
  • cover_image_id 关联 attachments 表,支持封面图片
  • 无物理外键约束,引用完整性由应用层保证
  • 使用触发器处理数据完整性(自动计算、自动继承)
  • 业务逻辑(权限、循环引用)在应用层实现

3.2 folder_members 表

CREATE TABLE folder_members (
    id UUID PRIMARY KEY,  -- UUID 由应用层生成
    folder_id UUID NOT NULL,  -- 逻辑外键,无物理约束
    user_id UUID NOT NULL,  -- 逻辑外键,无物理约束
    
    -- 成员角色 (使用 SMALLINT 存储)
    -- 1: owner, 2: editor, 3: viewer
    role SMALLINT NOT NULL DEFAULT 3 CHECK (role IN (1, 2, 3)),
    
    inherited BOOLEAN NOT NULL DEFAULT false, -- 是否从父文件夹继承权限
    invited_by UUID,  -- 逻辑外键,无物理约束
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT folder_members_unique UNIQUE (folder_id, user_id)
);

-- 表和字段注释
COMMENT ON TABLE folder_members IS '文件夹成员表';
COMMENT ON COLUMN folder_members.id IS '成员记录ID (UUID v7)';
COMMENT ON COLUMN folder_members.folder_id IS '文件夹ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folder_members.user_id IS '用户ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folder_members.role IS '成员角色: 1=owner, 2=editor, 3=viewer';
COMMENT ON COLUMN folder_members.inherited IS '是否从父文件夹继承权限';
COMMENT ON COLUMN folder_members.invited_by IS '邀请人用户ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folder_members.created_at IS '创建时间(自动记录时区)';
COMMENT ON COLUMN folder_members.updated_at IS '更新时间(自动记录时区)';

-- 索引
CREATE INDEX idx_folder_members_folder_id ON folder_members (folder_id);
CREATE INDEX idx_folder_members_user_id ON folder_members (user_id);
CREATE INDEX idx_folder_members_role ON folder_members (role);
CREATE INDEX idx_folder_members_inherited ON folder_members (inherited);

-- 触发器
CREATE TRIGGER update_folder_members_updated_at
    BEFORE UPDATE ON folder_members
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

成员角色枚举值映射表

名称 说明
1 owner 所有者
2 editor 编辑者
3 viewer 查看者

设计说明

  • 支持文件夹级别的协作权限
  • inherited 字段标记是否继承父文件夹权限
  • 权限角色:owner(所有者)、editor(编辑者)、viewer(查看者)
  • 唯一约束确保用户不会重复加入同一文件夹

3.3 与 projects 表的关联

-- projects 表新增字段(逻辑外键,无物理约束)
ALTER TABLE projects ADD COLUMN folder_id UUID;
CREATE INDEX idx_projects_folder_id ON projects (folder_id) WHERE deleted_at IS NULL AND folder_id IS NOT NULL;

-- 确保同一文件夹下项目名称唯一
ALTER TABLE projects ADD CONSTRAINT projects_name_unique UNIQUE (folder_id, name);

-- 字段注释
COMMENT ON COLUMN projects.folder_id IS '所属文件夹ID(逻辑外键,无物理约束)';

设计说明

  • 项目可以不属于任何文件夹(根目录项目)
  • 项目继承文件夹权限
  • 同一文件夹下项目名称唯一约束
  • 无物理外键约束,引用完整性由应用层保证

服务实现

4.1 枚举类型定义

# app/models/folder.py
from enum import IntEnum

class FolderCategory(IntEnum):
    """文件夹分类"""
    MY_PROJECTS = 1
    COLLABORATIVE_PROJECTS = 2

class MemberRole(IntEnum):
    """成员角色"""
    OWNER = 1
    EDITOR = 2
    VIEWER = 3

4.2 FolderRepository 类

# app/repositories/folder_repository.py
from typing import List, Optional, Dict, Any
from sqlmodel import select, func, and_, or_
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.folder import Folder, FolderCategory, MemberRole
from app.core.exceptions import NotFoundError
import logging

logger = logging.getLogger(__name__)

class FolderRepository:
    def __init__(self, session: AsyncSession):
        self.session = session

    async def get_by_id(self, folder_id: str) -> Optional[Folder]:
        """根据ID获取文件夹"""
        result = await self.session.execute(
            select(Folder).where(
                and_(
                    Folder.id == folder_id,
                    Folder.deleted_at.is_(None)
                )
            )
        )
        return result.scalar_one_or_none()

    async def get_by_parent(
        self,
        parent_id: Optional[str],
        user_id: str,
        page: int = 1,
        page_size: int = 20
    ) -> List[Folder]:
        """获取指定父文件夹下的子文件夹"""
        offset = (page - 1) * page_size
        
        query = select(Folder).where(
            and_(
                Folder.parent_folder_id == parent_id,
                Folder.owner_id == user_id,
                Folder.deleted_at.is_(None)
            )
        ).order_by(Folder.sort_order, Folder.created_at).offset(offset).limit(page_size)
        
        result = await self.session.execute(query)
        return list(result.scalars().all())

    async def count_by_parent(self, parent_id: Optional[str], user_id: str) -> int:
        """统计子文件夹数量"""
        result = await self.session.execute(
            select(func.count(Folder.id)).where(
                and_(
                    Folder.parent_folder_id == parent_id,
                    Folder.owner_id == user_id,
                    Folder.deleted_at.is_(None)
                )
            )
        )
        return result.scalar_one()

    async def exists_by_name(
        self,
        name: str,
        parent_id: Optional[str],
        owner_id: str,
        exclude_id: Optional[str] = None
    ) -> bool:
        """检查同级文件夹名称是否存在"""
        conditions = [
            Folder.name == name,
            Folder.parent_folder_id == parent_id,
            Folder.owner_id == owner_id,
            Folder.deleted_at.is_(None)
        ]
        
        if exclude_id:
            conditions.append(Folder.id != exclude_id)
        
        result = await self.session.execute(
            select(func.count(Folder.id)).where(and_(*conditions))
        )
        return result.scalar_one() > 0

    async def create(self, folder: Folder) -> Folder:
        """创建文件夹"""
        self.session.add(folder)
        await self.session.commit()
        await self.session.refresh(folder)
        logger.info("Created folder: %s, name: %s", folder.id, folder.name)
        return folder

    async def update(self, folder_id: str, update_data: Dict[str, Any]) -> Folder:
        """更新文件夹"""
        folder = await self.get_by_id(folder_id)
        if not folder:
            raise NotFoundError("文件夹不存在")
        
        for key, value in update_data.items():
            setattr(folder, key, value)
        
        await self.session.commit()
        await self.session.refresh(folder)
        logger.info("Updated folder: %s", folder_id)
        return folder

    async def soft_delete(self, folder_id: str, cascade: bool = False) -> None:
        """软删除文件夹"""
        from datetime import datetime, UTC
        
        folder = await self.get_by_id(folder_id)
        if not folder:
            raise NotFoundError("文件夹不存在")
        
        folder.deleted_at = datetime.now(UTC)
        
        if cascade:
            # 级联删除子文件夹
            await self._cascade_delete_children(folder_id)
        
        await self.session.commit()
        logger.info("Soft deleted folder: %s, cascade: %s", folder_id, cascade)

    async def _cascade_delete_children(self, folder_id: str) -> None:
        """递归删除子文件夹"""
        from datetime import datetime, UTC
        
        result = await self.session.execute(
            select(Folder).where(
                and_(
                    Folder.parent_folder_id == folder_id,
                    Folder.deleted_at.is_(None)
                )
            )
        )
        children = result.scalars().all()
        
        for child in children:
            child.deleted_at = datetime.now(UTC)
            await self._cascade_delete_children(child.id)

    async def has_children(self, folder_id: str) -> bool:
        """检查是否有子文件夹或项目"""
        # 检查子文件夹
        result = await self.session.execute(
            select(func.count(Folder.id)).where(
                and_(
                    Folder.parent_folder_id == folder_id,
                    Folder.deleted_at.is_(None)
                )
            )
        )
        if result.scalar_one() > 0:
            return True
        
        # 检查项目(需要导入 Project 模型)
        from app.models.project import Project
        result = await self.session.execute(
            select(func.count(Project.id)).where(
                and_(
                    Project.folder_id == folder_id,
                    Project.deleted_at.is_(None)
                )
            )
        )
        return result.scalar_one() > 0

    async def count_projects(self, folder_id: str) -> int:
        """统计文件夹内项目数量"""
        from app.models.project import Project
        result = await self.session.execute(
            select(func.count(Project.id)).where(
                and_(
                    Project.folder_id == folder_id,
                    Project.deleted_at.is_(None)
                )
            )
        )
        return result.scalar_one()

    async def count_subfolders(self, folder_id: str) -> int:
        """统计子文件夹数量"""
        result = await self.session.execute(
            select(func.count(Folder.id)).where(
                and_(
                    Folder.parent_folder_id == folder_id,
                    Folder.deleted_at.is_(None)
                )
            )
        )
        return result.scalar_one()

    async def would_create_cycle(
        self,
        folder_id: str,
        new_parent_id: Optional[str]
    ) -> bool:
        """检查移动是否会产生循环引用"""
        if not new_parent_id:
            return False
        
        if folder_id == new_parent_id:
            return True
        
        # 向上遍历父文件夹链
        current_id = new_parent_id
        while current_id:
            if current_id == folder_id:
                return True
            
            result = await self.session.execute(
                select(Folder.parent_folder_id).where(Folder.id == current_id)
            )
            current_id = result.scalar_one_or_none()
        
        return False

    async def move_folder(
        self,
        folder_id: str,
        new_parent_id: Optional[str]
    ) -> Folder:
        """移动文件夹"""
        folder = await self.get_by_id(folder_id)
        if not folder:
            raise NotFoundError("文件夹不存在")
        
        folder.parent_folder_id = new_parent_id
        # 路径和层级由触发器自动更新
        
        await self.session.commit()
        await self.session.refresh(folder)
        logger.info("Moved folder: %s to parent: %s", folder_id, new_parent_id)
        return folder

    async def get_path(self, folder_id: str) -> List[Dict[str, Any]]:
        """获取文件夹路径(面包屑导航)"""
        path = []
        current_id = folder_id
        
        while current_id:
            folder = await self.get_by_id(current_id)
            if not folder:
                break
            
            path.insert(0, {
                "id": folder.id,
                "name": folder.name
            })
            current_id = folder.parent_folder_id
        
        return path

    async def get_tree_structure(
        self,
        user_id: str,
        parent_id: Optional[str] = None,
        max_depth: Optional[int] = None,
        current_depth: int = 0,
        include_projects: bool = True,
        include_subprojects: bool = True
    ) -> List[Dict[str, Any]]:
        """递归获取文件夹树形结构(包含项目和子项目)"""
        if max_depth is not None and current_depth >= max_depth:
            return []
        
        folders = await self.get_by_parent(parent_id, user_id, page=1, page_size=1000)
        
        tree = []
        for folder in folders:
            node = {
                "id": folder.id,
                "name": folder.name,
                "type": "folder",
                "description": folder.description,
                "color": folder.color,
                "icon": folder.icon,
                "level": folder.level,
                "projectCount": await self.count_projects(folder.id),
                "subfolderCount": await self.count_subfolders(folder.id),
                "children": await self.get_tree_structure(
                    user_id, folder.id, max_depth, current_depth + 1,
                    include_projects, include_subprojects
                )
            }
            
            # 添加项目节点
            if include_projects:
                projects = await self._get_projects_in_folder(folder.id, user_id)
                for project in projects:
                    project_node = {
                        "id": project.project_id,
                        "name": project.name,
                        "type": "project",
                        "isSubproject": False,
                        "parentProjectId": None,
                        "screenplayId": None,
                        "children": []
                    }
                    
                    # 添加子项目节点
                    if include_subprojects:
                        subprojects = await self._get_subprojects(project.project_id, user_id)
                        for subproject in subprojects:
                            subproject_node = {
                                "id": subproject.project_id,
                                "name": subproject.name,
                                "type": "subproject",
                                "isSubproject": True,
                                "parentProjectId": subproject.parent_project_id,
                                "screenplayId": subproject.screenplay_id,
                                "children": []
                            }
                            project_node["children"].append(subproject_node)
                    
                    node["children"].append(project_node)
            
            tree.append(node)
        
        return tree

    async def _get_projects_in_folder(
        self,
        folder_id: str,
        user_id: str
    ) -> List[Any]:
        """获取文件夹内的父项目(parent_project_id = NULL)"""
        from app.models.project import Project
        result = await self.session.execute(
            select(Project).where(
                and_(
                    Project.folder_id == folder_id,
                    Project.parent_project_id.is_(None),
                    Project.deleted_at.is_(None)
                )
            ).order_by(Project.display_order, Project.created_at)
        )
        return list(result.scalars().all())

    async def _get_subprojects(
        self,
        parent_project_id: str,
        user_id: str
    ) -> List[Any]:
        """获取父项目的子项目"""
        from app.models.project import Project
        result = await self.session.execute(
            select(Project).where(
                and_(
                    Project.parent_project_id == parent_project_id,
                    Project.deleted_at.is_(None)
                )
            ).order_by(Project.display_order, Project.created_at)
        )
        return list(result.scalars().all())

    async def check_user_permission(
        self,
        user_id: str,
        folder_id: str,
        required_role: str = 'viewer'
    ) -> bool:
        """检查用户权限(包含继承权限)"""
        # 检查是否是所有者
        folder = await self.get_by_id(folder_id)
        if not folder:
            return False
        
        if folder.owner_id == user_id:
            return True
        
        # 检查直接权限
        from app.models.folder import FolderMember
        result = await self.session.execute(
            select(FolderMember.role).where(
                and_(
                    FolderMember.folder_id == folder_id,
                    FolderMember.user_id == user_id
                )
            )
        )
        role = result.scalar_one_or_none()
        
        if role is not None:
            return self._check_role_permission(role, required_role)
        
        # 检查继承权限
        if folder.parent_folder_id:
            return await self.check_user_permission(
                user_id, folder.parent_folder_id, required_role
            )
        
        return False

    def _check_role_permission(self, user_role: int, required_role: str) -> bool:
        """比较角色权限"""
        role_priority = {
            MemberRole.OWNER: 3,
            MemberRole.EDITOR: 2,
            MemberRole.VIEWER: 1
        }
        
        required_priority = {
            'owner': 3,
            'editor': 2,
            'viewer': 1
        }
        
        return role_priority.get(user_role, 0) >= required_priority.get(required_role, 0)

4.3 FolderService 类

# app/services/folder_service.py
from typing import List, Optional, Dict, Any
from uuid import UUID
from sqlmodel.ext.asyncio.session import AsyncSession
from datetime import datetime, UTC
from app.models.folder import Folder, FolderCategory
from app.repositories.folder_repository import FolderRepository
from app.schemas.folder import FolderCreate, FolderUpdate, FolderMove
from app.core.exceptions import NotFoundError, ValidationError, PermissionError
from app.core.id_generator import generate_uuid
import logging

logger = logging.getLogger(__name__)

class FolderService:
    def __init__(self, session: AsyncSession):
        self.repository = FolderRepository(session)
        self.session = session

    async def get_folders(
        self,
        user_id: str,
        parent_id: Optional[str] = None,
        page: int = 1,
        page_size: int = 20
    ) -> Dict[str, Any]:
        """获取文件夹列表"""
        # 检查父文件夹权限
        if parent_id:
            await self._check_folder_permission(user_id, parent_id, 'viewer')
            # 校验父文件夹存在性
            await self._validate_folder_exists(parent_id)

        folders = await self.repository.get_by_parent(
            parent_id, user_id, page, page_size
        )

        result = []
        for folder in folders:
            folder_data = {
                "id": folder.id,
                "name": folder.name,
                "description": folder.description,
                "parentFolderId": folder.parent_folder_id,
                "ownerId": folder.owner_id,
                "path": folder.path,
                "level": folder.level,
                "folderCategory": folder.folder_category,
                "sortOrder": folder.sort_order,
                "color": folder.color,
                "icon": folder.icon,
                "coverImageId": folder.cover_image_id,
                "coverImageUrl": await self._get_cover_image_url(folder.cover_image_id),
                "isShared": folder.is_shared,
                "createdAt": folder.created_at.isoformat(),
                "updatedAt": folder.updated_at.isoformat(),
                "projectCount": await self.repository.count_projects(folder.id),
                "subfolderCount": await self.repository.count_subfolders(folder.id),
            }
            result.append(folder_data)

        total = await self.repository.count_by_parent(parent_id, user_id)

        return {
            "items": result,
            "total": total,
            "page": page,
            "pageSize": page_size,
            "totalPages": (total + page_size - 1) // page_size
        }

    async def get_folder_tree(
        self,
        user_id: str,
        max_depth: Optional[int] = None,
        include_projects: bool = True,
        include_subprojects: bool = True
    ) -> Dict[str, Any]:
        """获取文件夹树形结构(包含项目和子项目)"""
        tree = await self.repository.get_tree_structure(
            user_id, None, max_depth, 0, include_projects, include_subprojects
        )
        return {
            "id": "root",
            "name": "根目录",
            "children": tree
        }

    async def get_folder(
        self,
        user_id: str,
        folder_id: str
    ) -> Folder:
        """获取文件夹详情"""
        folder = await self.repository.get_by_id(folder_id)
        if not folder:
            raise NotFoundError("文件夹不存在")

        # 检查权限
        await self._check_folder_permission(user_id, folder_id, 'viewer')

        return folder

    async def create_folder(
        self,
        user_id: str,
        folder_data: FolderCreate
    ) -> Folder:
        """创建文件夹"""
        # 校验用户存在性
        await self._validate_user_exists(user_id)
        
        # 检查父文件夹权限和存在性
        if folder_data.parent_folder_id:
            await self._validate_folder_exists(folder_data.parent_folder_id)
            await self._check_folder_permission(
                user_id, folder_data.parent_folder_id, 'editor'
            )

        # 校验封面图片存在性和所属权
        if folder_data.cover_image_id:
            await self._validate_attachment_exists_and_owned(
                folder_data.cover_image_id, user_id
            )

        # 检查同级名称唯一性
        if await self.repository.exists_by_name(
            folder_data.name, folder_data.parent_folder_id, user_id
        ):
            raise ValidationError("同一文件夹下不能有重名的子文件夹")

        # 创建根文件夹时必须指定 folder_category
        if folder_data.parent_folder_id is None and folder_data.folder_category is None:
            raise ValidationError("创建根文件夹时必须指定 folder_category")

        folder = Folder(
            id=generate_uuid(),
            name=folder_data.name,
            description=folder_data.description,
            parent_folder_id=folder_data.parent_folder_id,
            owner_id=user_id,
            folder_category=folder_data.folder_category or FolderCategory.MY_PROJECTS,
            color=folder_data.color,
            icon=folder_data.icon,
            cover_image_id=folder_data.cover_image_id,
            created_at=datetime.now(UTC),
            updated_at=datetime.now(UTC)
        )

        return await self.repository.create(folder)

    async def update_folder(
        self,
        user_id: str,
        folder_id: str,
        folder_data: FolderUpdate
    ) -> Folder:
        """更新文件夹"""
        folder = await self.repository.get_by_id(folder_id)
        if not folder:
            raise NotFoundError("文件夹不存在")

        # 检查权限
        await self._check_folder_permission(user_id, folder_id, 'editor')

        # 如果修改名称,检查同级唯一性
        if folder_data.name and folder_data.name != folder.name:
            if await self.repository.exists_by_name(
                folder_data.name, folder.parent_folder_id, user_id, folder_id
            ):
                raise ValidationError("同一文件夹下不能有重名的子文件夹")

        # 校验封面图片存在性和所属权
        if folder_data.cover_image_id:
            await self._validate_attachment_exists_and_owned(
                folder_data.cover_image_id, user_id
            )

        update_data = folder_data.dict(exclude_unset=True)
        update_data['updated_at'] = datetime.now(UTC)
        
        return await self.repository.update(folder_id, update_data)

    async def delete_folder(
        self,
        user_id: str,
        folder_id: str,
        cascade: bool = False
    ) -> None:
        """删除文件夹"""
        folder = await self.repository.get_by_id(folder_id)
        if not folder:
            raise NotFoundError("文件夹不存在")

        # 检查权限(只有 owner 可以删除)
        await self._check_folder_permission(user_id, folder_id, 'owner')

        # 检查是否有子文件夹或项目
        has_children = await self.repository.has_children(folder_id)
        if has_children and not cascade:
            raise ValidationError("文件夹不为空,无法删除。请先删除子文件夹和项目,或使用级联删除")

        await self.repository.soft_delete(folder_id, cascade=cascade)
        logger.info("User %s deleted folder %s, cascade=%s", user_id, folder_id, cascade)

    async def move_folder(
        self,
        user_id: str,
        folder_id: str,
        move_data: FolderMove
    ) -> Folder:
        """移动文件夹"""
        # 检查权限
        await self._check_folder_permission(user_id, folder_id, 'editor')

        # 检查目标文件夹权限和存在性
        if move_data.parent_folder_id:
            await self._validate_folder_exists(move_data.parent_folder_id)
            await self._check_folder_permission(
                user_id, move_data.parent_folder_id, 'editor'
            )

        # 检查循环引用
        if await self.repository.would_create_cycle(
            folder_id, move_data.parent_folder_id
        ):
            raise ValidationError("不能将文件夹移动到自己的子文件夹中")

        # 检查目标位置名称唯一性
        folder = await self.repository.get_by_id(folder_id)
        if await self.repository.exists_by_name(
            folder.name, move_data.parent_folder_id, user_id, folder_id
        ):
            raise ValidationError("目标位置已存在同名文件夹")

        result = await self.repository.move_folder(
            folder_id, move_data.parent_folder_id
        )
        logger.info("User %s moved folder %s to %s", user_id, folder_id, move_data.parent_folder_id)
        return result

    async def get_folder_path(
        self,
        user_id: str,
        folder_id: str
    ) -> List[Dict[str, Any]]:
        """获取文件夹路径(面包屑导航)"""
        await self._check_folder_permission(user_id, folder_id, 'viewer')
        return await self.repository.get_path(folder_id)

    # ==================== 引用完整性校验方法 ====================

    async def _validate_user_exists(self, user_id: str) -> None:
        """校验用户存在性"""
        from app.repositories.user_repository import UserRepository
        user_repo = UserRepository(self.session)
        user = await user_repo.get_by_id(user_id)
        if not user:
            raise ValidationError(f"用户不存在: {user_id}")

    async def _validate_folder_exists(self, folder_id: str) -> None:
        """校验文件夹存在性"""
        folder = await self.repository.get_by_id(folder_id)
        if not folder:
            raise ValidationError(f"文件夹不存在: {folder_id}")

    async def _validate_attachment_exists_and_owned(
        self,
        attachment_id: str,
        user_id: str
    ) -> None:
        """校验附件存在性和所属权"""
        from app.repositories.attachment_repository import AttachmentRepository
        attachment_repo = AttachmentRepository(self.session)
        attachment = await attachment_repo.get_by_id(attachment_id)
        
        if not attachment:
            raise ValidationError(f"附件不存在: {attachment_id}")
        
        if attachment.uploaded_by != user_id:
            raise PermissionError("无权使用此附件")

    async def _check_folder_permission(
        self,
        user_id: str,
        folder_id: str,
        required_role: str = 'viewer'
    ) -> None:
        """检查文件夹权限"""
        has_permission = await self.repository.check_user_permission(
            user_id, folder_id, required_role
        )
        if not has_permission:
            raise PermissionError("没有权限访问此文件夹")

    async def _get_cover_image_url(self, cover_image_id: Optional[str]) -> Optional[str]:
        """获取封面图片 URL"""
        if not cover_image_id:
            return None
        
        from app.repositories.attachment_repository import AttachmentRepository
        attachment_repo = AttachmentRepository(self.session)
        attachment = await attachment_repo.get_by_id(cover_image_id)
        return attachment.file_url if attachment else None

API 接口

5.1 获取文件夹列表

GET /api/v1/folders

查询参数

  • parent_id: 父文件夹 ID(可选,不传则获取根文件夹)
  • page: 页码(默认 1)
  • page_size: 每页数量(默认 20)

响应

{
  "items": [
    {
      "id": "01936d8f-1234-7890-abcd-ef1234567890",
      "name": "我的文件夹",
      "description": "文件夹描述",
      "parentFolderId": null,
      "ownerId": "01936d8f-5678-7890-abcd-ef1234567890",
      "path": "/我的文件夹",
      "level": 0,
      "sortOrder": 0,
      "color": "#FF5733",
      "icon": "folder",
      "coverImageId": "01936d8f-9abc-7890-abcd-ef1234567890",
      "coverImageUrl": "https://cdn.example.com/covers/folder-123.jpg",
      "isShared": false,
      "projectCount": 5,
      "subfolderCount": 2,
      "createdAt": "2025-01-18T10:00:00Z",
      "updatedAt": "2025-01-18T10:00:00Z"
    }
  ],
  "total": 10,
  "page": 1,
  "pageSize": 20,
  "totalPages": 1
}

5.2 获取文件夹树

GET /api/v1/folders/tree

查询参数

  • max_depth: 最大深度(可选,默认无限制)
  • include_projects: 是否包含项目节点(可选,默认 true
  • include_subprojects: 是否包含子项目节点(可选,默认 true

响应

{
  "id": "root",
  "name": "根目录",
  "children": [
    {
      "id": "01936d8f-1234-7890-abcd-ef1234567890",
      "name": "文件夹1",
      "type": "folder",
      "description": null,
      "color": "#FF5733",
      "icon": "folder",
      "coverImageUrl": "https://cdn.example.com/covers/folder-1.jpg",
      "level": 0,
      "projectCount": 5,
      "subfolderCount": 1,
      "children": [
        {
          "id": "01936d8f-5678-7890-abcd-ef1234567890",
          "name": "子文件夹",
          "type": "folder",
          "level": 1,
          "projectCount": 2,
          "subfolderCount": 0,
          "children": []
        },
        {
          "id": "01936d8f-proj-7890-abcd-ef1234567890",
          "name": "电影项目A",
          "type": "project",
          "isSubproject": false,
          "parentProjectId": null,
          "screenplayId": null,
          "children": [
            {
              "id": "01936d8f-subp-7890-abcd-ef1234567890",
              "name": "第一集剧本",
              "type": "subproject",
              "isSubproject": true,
              "parentProjectId": "01936d8f-proj-7890-abcd-ef1234567890",
              "screenplayId": "01936d8f-scrn-7890-abcd-ef1234567890",
              "children": []
            }
          ]
        }
      ]
    }
  ]
}

说明

  • type 字段标识节点类型:folder(文件夹)、project(父项目)、subproject(子项目)
  • 父项目节点包含子项目列表(children
  • 子项目节点包含 parentProjectIdscreenplayId 字段
  • 前端根据 type 字段渲染不同的图标和交互

5.3 获取文件夹详情

GET /api/v1/folders/{folder_id}

响应

{
  "id": "01936d8f-1234-7890-abcd-ef1234567890",
  "name": "我的文件夹",
  "description": "文件夹描述",
  "parentFolderId": null,
  "ownerId": "01936d8f-5678-7890-abcd-ef1234567890",
  "path": "/我的文件夹",
  "level": 0,
  "sortOrder": 0,
  "color": "#FF5733",
  "icon": "folder",
  "coverImageId": "01936d8f-9abc-7890-abcd-ef1234567890",
  "coverImageUrl": "https://cdn.example.com/covers/folder-123.jpg",
  "isShared": false,
  "createdAt": "2025-01-18T10:00:00Z",
  "updatedAt": "2025-01-18T10:00:00Z"
}

5.4 创建文件夹

POST /api/v1/folders

请求体

{
  "name": "新文件夹",
  "description": "文件夹描述",
  "parentFolderId": "01936d8f-1234-7890-abcd-ef1234567890",
  "color": "#FF5733",
  "icon": "folder",
  "coverImageId": "01936d8f-9abc-7890-abcd-ef1234567890"
}

响应:返回创建的文件夹对象

5.5 更新文件夹

PUT /api/v1/folders/{folder_id}

请求体

{
  "name": "更新后的名称",
  "description": "更新后的描述",
  "color": "#00FF00",
  "icon": "star",
  "coverImageId": "01936d8f-def0-7890-abcd-ef1234567890"
}

响应:返回更新后的文件夹对象

5.6 删除文件夹

DELETE /api/v1/folders/{folder_id}?cascade=false

查询参数

  • cascade: 是否级联删除子文件夹和项目(默认 false)

响应

{
  "message": "文件夹已删除"
}

5.7 移动文件夹

POST /api/v1/folders/{folder_id}/move

请求体

{
  "parentFolderId": "01936d8f-7890-7890-abcd-ef1234567890"
}

响应:返回移动后的文件夹对象

5.8 获取文件夹路径

GET /api/v1/folders/{folder_id}/path

响应

{
  "path": [
    {
      "id": "01936d8f-1234-7890-abcd-ef1234567890",
      "name": "一级文件夹"
    },
    {
      "id": "01936d8f-5678-7890-abcd-ef1234567890",
      "name": "二级文件夹"
    },
    {
      "id": "01936d8f-7890-7890-abcd-ef1234567890",
      "name": "当前文件夹"
    }
  ]
}

权限管理

6.1 权限检查(应用层实现)

权限检查逻辑在应用层实现,提供更好的灵活性和可测试性:

# app/repositories/folder_repository.py

async def check_user_permission(
    self,
    user_id: str,
    folder_id: str,
    required_role: str = 'viewer'
) -> bool:
    """检查用户权限(包含继承权限)"""
    # 检查是否是所有者
    folder = await self.get_by_id(folder_id)
    if not folder:
        return False
    
    if folder.owner_id == user_id:
        return True
    
    # 检查直接权限
    from app.models.folder import FolderMember
    result = await self.session.execute(
        select(FolderMember.role).where(
            and_(
                FolderMember.folder_id == folder_id,
                FolderMember.user_id == user_id
            )
        )
    )
    role = result.scalar_one_or_none()
    
    if role is not None:
        return self._check_role_permission(role, required_role)
    
    # 检查继承权限(递归)
    if folder.parent_folder_id:
        return await self.check_user_permission(
            user_id, folder.parent_folder_id, required_role
        )
    
    return False

def _check_role_permission(self, user_role: int, required_role: str) -> bool:
    """比较角色权限"""
    role_priority = {
        MemberRole.OWNER: 3,    # 1 -> 3
        MemberRole.EDITOR: 2,   # 2 -> 2
        MemberRole.VIEWER: 1    # 3 -> 1
    }
    
    required_priority = {
        'owner': 3,
        'editor': 2,
        'viewer': 1
    }
    
    return role_priority.get(user_role, 0) >= required_priority.get(required_role, 0)

设计说明

  • 权限检查在 Python 代码中实现,易于测试和调试
  • 支持权限继承(递归检查父文件夹)
  • 所有者自动拥有所有权限
  • 使用 IntEnum 映射角色优先级

6.2 循环引用检测(应用层实现)

循环引用检测在应用层实现:

# app/repositories/folder_repository.py

async def would_create_cycle(
    self,
    folder_id: str,
    new_parent_id: Optional[str]
) -> bool:
    """检查移动是否会产生循环引用"""
    if not new_parent_id:
        return False
    
    if folder_id == new_parent_id:
        return True
    
    # 向上遍历父文件夹链
    current_id = new_parent_id
    while current_id:
        if current_id == folder_id:
            return True
        
        result = await self.session.execute(
            select(Folder.parent_folder_id).where(Folder.id == current_id)
        )
        current_id = result.scalar_one_or_none()
    
    return False

设计说明

  • 在移动文件夹前检查循环引用
  • 向上遍历父文件夹链,检查是否包含当前文件夹
  • 在 Service 层调用,提供友好的错误提示

6.3 名称唯一性检查(应用层 + 数据库索引)

双重保护机制

  1. 应用层主动检查(第一道防线):
# app/services/folder_service.py

async def create_folder(self, user_id: str, folder_data: FolderCreate) -> Folder:
    # 检查同级名称唯一性
    if await self.repository.exists_by_name(
        folder_data.name, 
        folder_data.parent_folder_id, 
        user_id
    ):
        raise ValidationError("同一文件夹下不能有重名的子文件夹")
    
    # 创建文件夹
    try:
        folder = Folder(...)
        return await self.repository.create(folder)
    except IntegrityError as e:
        # 捕获数据库唯一索引冲突(并发情况)
        if 'folders_name_unique' in str(e):
            raise ValidationError("同一文件夹下不能有重名的子文件夹")
        raise
  1. 数据库唯一索引(最后防线):
-- 已在表定义中创建部分唯一索引
CREATE UNIQUE INDEX idx_folders_name_unique_with_parent ...
CREATE UNIQUE INDEX idx_folders_name_unique_root ...

优势

  • 应用层提供友好的错误提示
  • 数据库索引防止并发竞态条件
  • 双重保护确保数据一致性

数据模型

7.1 Folder 模型

# app/models/folder.py
from sqlmodel import SQLModel, Field, Relationship
from sqlalchemy import Column, SmallInteger
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from app.utils.id_generator import generate_uuid
from datetime import datetime, UTC
from typing import TYPE_CHECKING, Optional, List
from uuid import UUID
from enum import IntEnum

if TYPE_CHECKING:
    from app.models.user import User

class FolderCategory(IntEnum):
    """文件夹分类"""
    MY_PROJECTS = 1
    COLLABORATIVE_PROJECTS = 2

class MemberRole(IntEnum):
    """成员角色"""
    OWNER = 1
    EDITOR = 2
    VIEWER = 3

class Folder(SQLModel, table=True):
    """文件夹表 - 应用层生成 UUID,无物理外键"""
    __tablename__ = "folders"
    
    def __repr__(self):
        return f"<Folder(id={self.id}, name={self.name}, level={self.level})>"

    # 主键 (UUID v7 - 应用层生成)
    id: UUID = Field(
        sa_column=Column(
            PG_UUID(as_uuid=True),
            primary_key=True,
            default=generate_uuid  # 应用层生成,非数据库默认值
        ),
        description="文件夹唯一标识"
    )
    
    name: str = Field(max_length=255, index=True)
    description: Optional[str] = Field(default=None)
    
    # 逻辑外键,无物理约束
    parent_folder_id: Optional[UUID] = Field(
        default=None,
        sa_column=Column(PG_UUID(as_uuid=True), nullable=True, index=True),
        description="父文件夹ID - 应用层验证"
    )
    owner_id: UUID = Field(
        sa_column=Column(PG_UUID(as_uuid=True), nullable=False, index=True),
        description="所有者用户ID - 应用层验证"
    )

    # 路径信息(用于快速查询)
    path: str = Field(default="/", max_length=1000, index=True)
    level: int = Field(default=0)
    
    # 文件夹分类(SMALLINT 存储)
    folder_category: int = Field(
        default=FolderCategory.MY_PROJECTS,
        sa_column=Column(SmallInteger, nullable=False)
    )

    # 排序
    sort_order: int = Field(default=0)

    # 样式
    color: Optional[str] = Field(default=None, max_length=20)
    icon: Optional[str] = Field(default=None, max_length=50)
    cover_image_id: Optional[UUID] = Field(
        default=None,
        sa_column=Column(PG_UUID(as_uuid=True), nullable=True, index=True),
        description="封面图片ID - 应用层验证"
    )

    # 共享
    is_shared: bool = Field(default=False)

    # 时间戳(使用 UTC aware datetime)
    created_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="创建时间(UTC)"
    )
    updated_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="更新时间(UTC)"
    )
    deleted_at: Optional[datetime] = Field(
        default=None,
        description="软删除时间(UTC)"
    )

    # Relationship 配置(使用 primaryjoin,因为无物理外键)
    owner: Optional["User"] = Relationship(
        sa_relationship_kwargs={
            "primaryjoin": "Folder.owner_id == User.user_id",
            "foreign_keys": "[Folder.owner_id]",
        }
    )
    
    parent_folder: Optional["Folder"] = Relationship(
        sa_relationship_kwargs={
            "primaryjoin": "Folder.parent_folder_id == Folder.id",
            "foreign_keys": "[Folder.parent_folder_id]",
        }
    )

class FolderMember(SQLModel, table=True):
    """文件夹成员表"""
    __tablename__ = "folder_members"
    
    def __repr__(self):
        return f"<FolderMember(id={self.id}, folder_id={self.folder_id}, user_id={self.user_id}, role={self.role})>"

    # 主键 (UUID v7 - 应用层生成)
    id: UUID = Field(
        sa_column=Column(
            PG_UUID(as_uuid=True),
            primary_key=True,
            default=generate_uuid
        ),
        description="成员记录唯一标识"
    )
    
    # 逻辑外键,无物理约束
    folder_id: UUID = Field(
        sa_column=Column(PG_UUID(as_uuid=True), nullable=False, index=True),
        description="文件夹ID - 应用层验证"
    )
    user_id: UUID = Field(
        sa_column=Column(PG_UUID(as_uuid=True), nullable=False, index=True),
        description="用户ID - 应用层验证"
    )
    
    # 成员角色(SMALLINT 存储)
    role: int = Field(
        default=MemberRole.VIEWER,
        sa_column=Column(SmallInteger, nullable=False)
    )
    
    inherited: bool = Field(default=False)
    invited_by: Optional[UUID] = Field(
        default=None,
        sa_column=Column(PG_UUID(as_uuid=True), nullable=True),
        description="邀请人用户ID - 应用层验证"
    )
    
    created_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="创建时间(UTC)"
    )
    updated_at: datetime = Field(
        default_factory=lambda: datetime.now(UTC),
        description="更新时间(UTC)"
    )

7.2 Schema 定义

# app/schemas/folder.py
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional
from datetime import datetime

class FolderCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=255)
    description: Optional[str] = None
    parent_folder_id: Optional[str] = None
    folder_category: Optional[int] = Field(None, ge=1, le=2)  # 1=我的项目, 2=协作项目
    color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$")
    icon: Optional[str] = None
    cover_image_id: Optional[str] = None

class FolderUpdate(BaseModel):
    name: Optional[str] = Field(None, min_length=1, max_length=255)
    description: Optional[str] = None
    color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$")
    icon: Optional[str] = None
    cover_image_id: Optional[str] = None
    sort_order: Optional[int] = None

class FolderMove(BaseModel):
    parent_folder_id: Optional[str] = None

class FolderResponse(BaseModel):
    id: str
    name: str
    description: Optional[str]
    parent_folder_id: Optional[str] = Field(None, alias="parentFolderId")
    owner_id: str = Field(alias="ownerId")
    path: str
    level: int
    folder_category: int = Field(alias="folderCategory")
    sort_order: int = Field(alias="sortOrder")
    color: Optional[str]
    icon: Optional[str]
    cover_image_id: Optional[str] = Field(None, alias="coverImageId")
    cover_image_url: Optional[str] = Field(None, alias="coverImageUrl")
    is_shared: bool = Field(alias="isShared")
    created_at: datetime = Field(alias="createdAt")
    updated_at: datetime = Field(alias="updatedAt")
    subfolder_count: Optional[int] = Field(None, alias="subfolderCount")
    project_count: Optional[int] = Field(None, alias="projectCount")

    model_config = ConfigDict(from_attributes=True, populate_by_name=True)

变更记录

v3.7 (2026-01-31)

  • 集成子项目功能
    • 更新文件夹层级结构说明(包含父子项目关系)
    • 更新 GET /api/v1/folders/tree 接口(新增 include_projectsinclude_subprojects 参数)
    • 更新 get_tree_structure() 方法(支持项目和子项目节点)
    • 新增 _get_projects_in_folder() 方法(获取文件夹内的父项目)
    • 新增 _get_subprojects() 方法(获取父项目的子项目)
    • 响应格式新增 type 字段(folder | project | subproject
    • 项目节点新增 isSubprojectparentProjectIdscreenplayId 字段
    • 前端可根据 type 字段渲染不同的图标和交互

v3.6 (2026-01-29)

  • 修正时间戳规范(符合 ADR 006)
    • 数据库层:所有事件时间字段使用 TIMESTAMPTZfolders, folder_members, folder_shares, folder_export_jobs
    • 默认值:统一使用 now() 而非 CURRENT_TIMESTAMP
    • 字段注释:改为"自动记录时区"而非"UTC"
    • 理由:事件时间表示真实世界时间点,必须包含时区信息

v3.5 (2026-01-29) - 已废弃

  • 错误的时间戳规范(已被 v3.6 修正):
    • 错误地使用 TIMESTAMP WITHOUT TIME ZONE 记录事件时间
    • 违反了 PostgreSQL 最佳实践和 ADR 006 决策

v3.4 (2026-01-28)

  • 完整修复数据库设计问题
    • UUID 生成:移除所有 DEFAULT gen_uuid_v7(),改为应用层生成
    • SQL 语法:修复触发器函数分隔符($$$
    • 唯一约束:使用部分唯一索引处理 NULL 值
    • 职责划分:明确数据库层(触发器)vs 应用层(业务逻辑)
    • 移除 SQL 函数:权限检查、循环引用检测、可访问文件夹查询改为应用层实现
    • 保留触发器:自动更新时间戳、自动计算路径层级、自动继承分类
    • 双重保护:应用层主动检查 + 数据库索引兜底

v3.3 (2026-01-28)

  • 修复技术栈规范符合度问题:
    • 移除所有物理外键约束,改为逻辑外键
    • 时间戳使用 CURRENT_TIMESTAMP 替代 now()
    • Python 模型使用 datetime.UTC 替代 timezone.utc
    • 移除 SQLModel 的 foreign_key 参数
    • 添加应用层引用完整性校验(用户、文件夹、附件存在性)
    • 添加 Python IntEnum 枚举定义(FolderCategory、MemberRole)
    • 添加完整的 SQL 表和字段注释
    • 补充完整的 FolderRepository 实现
    • 增强 FolderService 的引用完整性校验
    • 添加日志记录

v3.2 (2025-01-27)

  • 重构枚举字段实现方式:
    • member_role 从 PostgreSQL ENUM 改为 SMALLINT (1=owner, 2=editor, 3=viewer)
    • export_status 从 PostgreSQL ENUM 改为 SMALLINT (1-5)
    • 更新权限检查函数使用 SMALLINT 类型
    • 添加枚举值映射表
  • 原因:更好的性能、更容易扩展、与项目其他模块保持一致

v3.1 (2025-01-20)

  • 补充文件夹克隆功能(Clone)
  • 补充文件夹导出功能(Export)
  • 补充文件夹分享功能(Share)
  • 补充文件夹统计信息接口
  • 补充批量操作接口
  • 新增 folder_shares 表设计
  • 新增 folder_export_jobs 表设计

v3.0 (2025-01-20)

  • 完善数据库设计(folders 表和 folder_members 表)
  • 新增封面图片字段(cover_image_id)
  • 补充权限管理函数(check_folder_permission、check_folder_cycle)
  • 补充完整的 API 接口文档
  • 补充数据模型和 Schema 定义
  • 移除重复的 v1.0 内容

v2.0 (2025-01-18)

  • 迁移到 UUID v7 主键
  • 所有 ID 字段从 int 改为 string
  • 添加 pathlevel 字段优化查询
  • 实现完整的 CRUD 和移动功能
  • 添加循环引用检测
  • 支持软删除和级联删除

v1.0 (2025-01-27)

  • 初始版本设计

相关文档


补充功能

8.1 文件夹克隆(Clone)

功能说明:复制文件夹及其内容,支持两种模式:

  • 仅克隆内容:仅复制当前文件夹下的项目,不包含子文件夹
  • 递归克隆:复制当前文件夹及其所有子文件夹和项目

API 接口

POST /api/v1/folders/{folder_id}/clone

请求体

{
  "mode": "content",  // "content" | "recursive"
  "targetName": "我的文件夹 (副本)",  // 可选,默认为 "{原名称} (副本)"
  "targetParentId": null  // 可选,克隆到的目标父文件夹,null 表示同级
}

响应

{
  "id": "01936d8f-abcd-7890-abcd-ef1234567890",
  "name": "我的文件夹 (副本)",
  "parentFolderId": null,
  "clonedFrom": "01936d8f-1234-7890-abcd-ef1234567890",
  "mode": "content",
  "itemsCloned": {
    "folders": 0,
    "projects": 5
  },
  "createdAt": "2025-01-20T10:00:00Z"
}

实现要点

  1. 权限检查:需要源文件夹的 viewer 权限,目标位置的 editor 权限
  2. 名称处理:自动添加 "(副本)" 后缀,如果重名则添加数字 "(副本 2)"
  3. 递归克隆
    • 深度优先遍历子文件夹
    • 保持原有的文件夹结构
    • 项目克隆时重新生成 ID
    • 资源文件需要复制(或共享引用)
  4. 异步处理:大文件夹克隆应使用后台任务,返回任务 ID
  5. 进度跟踪:支持查询克隆进度

8.2 文件夹导出(Export)

功能说明:将文件夹及其内容打包为 .zip 文件导出

API 接口

POST /api/v1/folders/{folder_id}/export

请求体

{
  "format": "zip",  // 目前仅支持 zip
  "includeSubfolders": true,  // 是否包含子文件夹
  "includeResources": true,  // 是否包含资源文件
  "exportFormat": "native"  // "native" | "json" (native 为项目原生格式,json 为可读格式)
}

响应

{
  "exportJobId": "01936d8f-export-7890-abcd-ef1234567890",
  "status": "pending",  // "pending" | "processing" | "completed" | "failed"
  "progress": 0,
  "estimatedSize": 1024000,  // 预估文件大小(字节)
  "createdAt": "2025-01-20T10:00:00Z"
}

查询导出状态

GET /api/v1/folders/export/{export_job_id}

响应

{
  "exportJobId": "01936d8f-export-7890-abcd-ef1234567890",
  "status": "completed",
  "progress": 100,
  "downloadUrl": "https://cdn.example.com/exports/folder-123.zip",
  "fileSize": 1048576,
  "expiresAt": "2025-01-21T10:00:00Z",  // 下载链接过期时间(24小时)
  "completedAt": "2025-01-20T10:05:00Z"
}

实现要点

  1. 异步处理:使用后台任务处理导出
  2. 文件结构
    folder-name.zip
    ├── folder-name/
    │   ├── project-1/
    │   │   ├── project.json (项目元数据)
    │   │   ├── storyboards/
    │   │   ├── resources/
    │   │   └── timeline.json
    │   ├── project-2/
    │   └── subfolder/
    │       └── project-3/
    └── manifest.json (导出清单)
    
  3. 临时存储:导出文件存储在临时目录,24小时后自动清理
  4. 权限检查:需要文件夹的 viewer 权限
  5. 大小限制:单次导出不超过 5GB

8.3 文件夹分享(Share)

功能说明:将文件夹分享给其他用户或生成公开链接

API 接口

POST /api/v1/folders/{folder_id}/share

请求体

{
  "type": "user",  // "user" | "link"
  "users": [  // type="user" 时必填
    {
      "userId": "01936d8f-5678-7890-abcd-ef1234567890",
      "role": "viewer"  // "viewer" | "editor"
    }
  ],
  "linkSettings": {  // type="link" 时必填
    "accessLevel": "viewer",  // "viewer" | "editor"
    "expiresAt": "2025-02-20T10:00:00Z",  // 可选,链接过期时间
    "password": "abc123"  // 可选,访问密码
  }
}

响应(用户分享)

{
  "sharedWith": [
    {
      "userId": "01936d8f-5678-7890-abcd-ef1234567890",
      "role": "viewer",
      "sharedAt": "2025-01-20T10:00:00Z"
    }
  ]
}

响应(链接分享)

{
  "shareLink": "https://app.example.com/shared/f/abc123def456",
  "accessLevel": "viewer",
  "expiresAt": "2025-02-20T10:00:00Z",
  "hasPassword": true,
  "createdAt": "2025-01-20T10:00:00Z"
}

实现要点

  1. 权限检查:需要文件夹的 owner 或 editor 权限
  2. 继承权限:子文件夹和项目自动继承分享权限
  3. 通知机制:分享给用户时发送通知
  4. 链接管理:支持撤销、更新分享链接
  5. 访问日志:记录分享链接的访问记录

8.4 文件夹统计信息

功能说明:获取文件夹的详细统计信息

API 接口

GET /api/v1/folders/{folder_id}/stats

查询参数

  • recursive: 是否递归统计子文件夹(默认 false)

响应

{
  "folderId": "01936d8f-1234-7890-abcd-ef1234567890",
  "name": "我的文件夹",
  "stats": {
    "subfolderCount": 5,
    "projectCount": 12,
    "totalSize": 1048576000,  // 总大小(字节)
    "resourceCount": {
      "images": 45,
      "videos": 12,
      "audio": 8
    },
    "lastActivity": "2025-01-20T09:30:00Z",
    "memberCount": 3
  },
  "recursive": false
}

8.5 批量操作

功能说明:批量移动、删除、导出文件夹

批量移动

POST /api/v1/folders/batch/move

请求体

{
  "folderIds": [
    "01936d8f-1234-7890-abcd-ef1234567890",
    "01936d8f-5678-7890-abcd-ef1234567890"
  ],
  "targetParentId": "01936d8f-9abc-7890-abcd-ef1234567890"
}

批量删除

POST /api/v1/folders/batch/delete

请求体

{
  "folderIds": [
    "01936d8f-1234-7890-abcd-ef1234567890",
    "01936d8f-5678-7890-abcd-ef1234567890"
  ],
  "cascade": false
}

响应

{
  "success": [
    "01936d8f-1234-7890-abcd-ef1234567890"
  ],
  "failed": [
    {
      "folderId": "01936d8f-5678-7890-abcd-ef1234567890",
      "error": "没有权限删除此文件夹"
    }
  ]
}

相关数据表

9.1 folder_shares 表(文件夹分享)

CREATE TABLE folder_shares (
    id UUID PRIMARY KEY,  -- UUID 由应用层生成
    folder_id UUID NOT NULL,  -- 逻辑外键,无物理约束
    share_type TEXT NOT NULL CHECK (share_type IN ('user', 'link')),
    
    -- 用户分享
    shared_with_user_id UUID,  -- 逻辑外键,无物理约束
    
    -- 角色 (使用 SMALLINT 存储)
    -- 1: owner, 2: editor, 3: viewer
    role SMALLINT CHECK (role IN (1, 2, 3)),
    
    -- 链接分享
    share_token TEXT UNIQUE,
    access_level SMALLINT CHECK (access_level IN (1, 2, 3)),
    password_hash TEXT,
    expires_at TIMESTAMPTZ,
    access_count INTEGER DEFAULT 0,
    
    -- 审计
    created_by UUID NOT NULL,  -- 逻辑外键,无物理约束
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    revoked_at TIMESTAMPTZ,
    
    -- 约束
    CONSTRAINT folder_shares_type_check CHECK (
        (share_type = 'user' AND shared_with_user_id IS NOT NULL AND share_token IS NULL) OR
        (share_type = 'link' AND share_token IS NOT NULL AND shared_with_user_id IS NULL)
    )
);

-- 表和字段注释
COMMENT ON TABLE folder_shares IS '文件夹分享表';
COMMENT ON COLUMN folder_shares.id IS '分享记录ID (UUID v7)';
COMMENT ON COLUMN folder_shares.folder_id IS '文件夹ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folder_shares.share_type IS '分享类型: user=用户分享, link=链接分享';
COMMENT ON COLUMN folder_shares.shared_with_user_id IS '被分享用户ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folder_shares.role IS '用户分享角色: 1=owner, 2=editor, 3=viewer';
COMMENT ON COLUMN folder_shares.share_token IS '分享链接令牌';
COMMENT ON COLUMN folder_shares.access_level IS '链接访问级别: 1=owner, 2=editor, 3=viewer';
COMMENT ON COLUMN folder_shares.password_hash IS '链接访问密码哈希';
COMMENT ON COLUMN folder_shares.expires_at IS '链接过期时间(自动记录时区)';
COMMENT ON COLUMN folder_shares.access_count IS '链接访问次数';
COMMENT ON COLUMN folder_shares.created_by IS '创建人用户ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folder_shares.created_at IS '创建时间(自动记录时区)';
COMMENT ON COLUMN folder_shares.revoked_at IS '撤销时间(自动记录时区)';

-- 索引
CREATE INDEX idx_folder_shares_folder_id ON folder_shares (folder_id);
CREATE INDEX idx_folder_shares_user_id ON folder_shares (shared_with_user_id) WHERE shared_with_user_id IS NOT NULL;
CREATE INDEX idx_folder_shares_token ON folder_shares (share_token) WHERE share_token IS NOT NULL;
CREATE INDEX idx_folder_shares_expires_at ON folder_shares (expires_at) WHERE expires_at IS NOT NULL;

9.2 folder_export_jobs 表(导出任务)

CREATE TABLE folder_export_jobs (
    id UUID PRIMARY KEY,  -- UUID 由应用层生成
    folder_id UUID NOT NULL,  -- 逻辑外键,无物理约束
    user_id UUID NOT NULL,  -- 逻辑外键,无物理约束
    
    -- 导出配置
    format TEXT NOT NULL DEFAULT 'zip',
    include_subfolders BOOLEAN NOT NULL DEFAULT true,
    include_resources BOOLEAN NOT NULL DEFAULT true,
    export_format TEXT NOT NULL DEFAULT 'native',
    
    -- 导出状态 (使用 SMALLINT 存储)
    -- 1: pending, 2: processing, 3: completed, 4: failed, 5: cancelled
    status SMALLINT NOT NULL DEFAULT 1 CHECK (status IN (1, 2, 3, 4, 5)),
    
    progress INTEGER NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
    
    -- 结果
    file_url TEXT,
    file_size BIGINT,
    estimated_size BIGINT,
    error_message TEXT,
    
    -- 时间戳(使用 TIMESTAMPTZ 记录事件时间)
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    expires_at TIMESTAMPTZ  -- 下载链接过期时间
);

-- 表和字段注释
COMMENT ON TABLE folder_export_jobs IS '文件夹导出任务表';
COMMENT ON COLUMN folder_export_jobs.id IS '导出任务ID (UUID v7)';
COMMENT ON COLUMN folder_export_jobs.folder_id IS '文件夹ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folder_export_jobs.user_id IS '用户ID(逻辑外键,无物理约束)';
COMMENT ON COLUMN folder_export_jobs.format IS '导出格式(默认 zip)';
COMMENT ON COLUMN folder_export_jobs.include_subfolders IS '是否包含子文件夹';
COMMENT ON COLUMN folder_export_jobs.include_resources IS '是否包含资源文件';
COMMENT ON COLUMN folder_export_jobs.export_format IS '导出格式类型: native=原生格式, json=JSON格式';
COMMENT ON COLUMN folder_export_jobs.status IS '导出状态: 1=pending, 2=processing, 3=completed, 4=failed, 5=cancelled';
COMMENT ON COLUMN folder_export_jobs.progress IS '导出进度(0-100)';
COMMENT ON COLUMN folder_export_jobs.file_url IS '导出文件URL';
COMMENT ON COLUMN folder_export_jobs.file_size IS '文件大小(字节)';
COMMENT ON COLUMN folder_export_jobs.estimated_size IS '预估文件大小(字节)';
COMMENT ON COLUMN folder_export_jobs.error_message IS '错误信息';
COMMENT ON COLUMN folder_export_jobs.created_at IS '创建时间(自动记录时区)';
COMMENT ON COLUMN folder_export_jobs.started_at IS '开始时间(自动记录时区)';
COMMENT ON COLUMN folder_export_jobs.completed_at IS '完成时间(自动记录时区)';
COMMENT ON COLUMN folder_export_jobs.expires_at IS '下载链接过期时间(自动记录时区)';

-- 索引
CREATE INDEX idx_folder_export_jobs_folder_id ON folder_export_jobs (folder_id);
CREATE INDEX idx_folder_export_jobs_user_id ON folder_export_jobs (user_id);
CREATE INDEX idx_folder_export_jobs_status ON folder_export_jobs (status);
CREATE INDEX idx_folder_export_jobs_expires_at ON folder_export_jobs (expires_at) WHERE expires_at IS NOT NULL;

导出状态枚举值映射表

名称 说明
1 pending 等待处理
2 processing 处理中
3 completed 已完成
4 failed 失败
5 cancelled 已取消

变更记录

v3.1 (2025-01-20)

  • 补充文件夹克隆功能(Clone)
  • 补充文件夹导出功能(Export)
  • 补充文件夹分享功能(Share)
  • 补充文件夹统计信息接口
  • 补充批量操作接口
  • 新增 folder_shares 表设计
  • 新增 folder_export_jobs 表设计