# 文件夹管理服务 > **文档版本**:v3.8 > **最后更新**:2026-02-02 > **变更**:完成代码实现,修复 Model 层、补充 Repository 层、创建数据库迁移文件 --- ## 目录 1. [服务概述](#服务概述) 2. [核心功能](#核心功能) 3. [数据库设计](#数据库设计) 4. [服务实现](#服务实现) 5. [API 接口](#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 表 ```sql 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()` 函数),不使用数据库默认值 - `path` 和 `level` 字段由触发器自动计算,提升查询性能 - `folder_category` 由触发器自动继承父文件夹分类 - **名称唯一性**:使用部分唯一索引处理 NULL 值,配合应用层主动检查 - `cover_image_id` 关联 `attachments` 表,支持封面图片 - 无物理外键约束,引用完整性由应用层保证 - 使用触发器处理数据完整性(自动计算、自动继承) - 业务逻辑(权限、循环引用)在应用层实现 ### 3.2 folder_members 表 ```sql 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 表的关联 ```sql -- 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 枚举类型定义 ```python # 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 类 ```python # 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 类 ```python # 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) **响应**: ```json { "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`) **响应**: ```json { "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`) - 子项目节点包含 `parentProjectId` 和 `screenplayId` 字段 - 前端根据 `type` 字段渲染不同的图标和交互 ### 5.3 获取文件夹详情 ``` GET /api/v1/folders/{folder_id} ``` **响应**: ```json { "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 ``` **请求体**: ```json { "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} ``` **请求体**: ```json { "name": "更新后的名称", "description": "更新后的描述", "color": "#00FF00", "icon": "star", "coverImageId": "01936d8f-def0-7890-abcd-ef1234567890" } ``` **响应**:返回更新后的文件夹对象 ### 5.6 删除文件夹 ``` DELETE /api/v1/folders/{folder_id}?cascade=false ``` **查询参数**: - `cascade`: 是否级联删除子文件夹和项目(默认 false) **响应**: ```json { "message": "文件夹已删除" } ``` ### 5.7 移动文件夹 ``` POST /api/v1/folders/{folder_id}/move ``` **请求体**: ```json { "parentFolderId": "01936d8f-7890-7890-abcd-ef1234567890" } ``` **响应**:返回移动后的文件夹对象 ### 5.8 获取文件夹路径 ``` GET /api/v1/folders/{folder_id}/path ``` **响应**: ```json { "path": [ { "id": "01936d8f-1234-7890-abcd-ef1234567890", "name": "一级文件夹" }, { "id": "01936d8f-5678-7890-abcd-ef1234567890", "name": "二级文件夹" }, { "id": "01936d8f-7890-7890-abcd-ef1234567890", "name": "当前文件夹" } ] } ``` --- ## 权限管理 ### 6.1 权限检查(应用层实现) 权限检查逻辑在应用层实现,提供更好的灵活性和可测试性: ```python # 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 循环引用检测(应用层实现) 循环引用检测在应用层实现: ```python # 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. **应用层主动检查**(第一道防线): ```python # 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 ``` 2. **数据库唯一索引**(最后防线): ```sql -- 已在表定义中创建部分唯一索引 CREATE UNIQUE INDEX idx_folders_name_unique_with_parent ... CREATE UNIQUE INDEX idx_folders_name_unique_root ... ``` **优势**: - 应用层提供友好的错误提示 - 数据库索引防止并发竞态条件 - 双重保护确保数据一致性 --- ## 数据模型 ### 7.1 Folder 模型 ```python # 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"" # 主键 (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"" # 主键 (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 定义 ```python # 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_projects` 和 `include_subprojects` 参数) - 更新 `get_tree_structure()` 方法(支持项目和子项目节点) - 新增 `_get_projects_in_folder()` 方法(获取文件夹内的父项目) - 新增 `_get_subprojects()` 方法(获取父项目的子项目) - 响应格式新增 `type` 字段(`folder` | `project` | `subproject`) - 项目节点新增 `isSubproject`、`parentProjectId`、`screenplayId` 字段 - 前端可根据 `type` 字段渲染不同的图标和交互 ### v3.6 (2026-01-29) - ✅ **修正时间戳规范(符合 ADR 006)**: - **数据库层**:所有事件时间字段使用 `TIMESTAMPTZ`(`folders`, `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` - ✅ 添加 `path` 和 `level` 字段优化查询 - ✅ 实现完整的 CRUD 和移动功能 - ✅ 添加循环引用检测 - ✅ 支持软删除和级联删除 ### v1.0 (2025-01-27) - 初始版本设计 --- ## 相关文档 - [ADR 001: UUID v7 迁移](../../../architecture/adrs/001-uuid-v7-migration.md) - [RFC 016: 文件夹服务实现](../../../server/rfcs/016-folder-service-implementation.md) - [项目管理服务](./project-service.md) - [数据库设计文档](../../../requirements/database-design.md) - [系统架构设计](../../03-system-design.md) --- ## 补充功能 ### 8.1 文件夹克隆(Clone) **功能说明**:复制文件夹及其内容,支持两种模式: - **仅克隆内容**:仅复制当前文件夹下的项目,不包含子文件夹 - **递归克隆**:复制当前文件夹及其所有子文件夹和项目 **API 接口**: ``` POST /api/v1/folders/{folder_id}/clone ``` **请求体**: ```json { "mode": "content", // "content" | "recursive" "targetName": "我的文件夹 (副本)", // 可选,默认为 "{原名称} (副本)" "targetParentId": null // 可选,克隆到的目标父文件夹,null 表示同级 } ``` **响应**: ```json { "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 ``` **请求体**: ```json { "format": "zip", // 目前仅支持 zip "includeSubfolders": true, // 是否包含子文件夹 "includeResources": true, // 是否包含资源文件 "exportFormat": "native" // "native" | "json" (native 为项目原生格式,json 为可读格式) } ``` **响应**: ```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} ``` **响应**: ```json { "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 ``` **请求体**: ```json { "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" // 可选,访问密码 } } ``` **响应(用户分享)**: ```json { "sharedWith": [ { "userId": "01936d8f-5678-7890-abcd-ef1234567890", "role": "viewer", "sharedAt": "2025-01-20T10:00:00Z" } ] } ``` **响应(链接分享)**: ```json { "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) **响应**: ```json { "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 ``` **请求体**: ```json { "folderIds": [ "01936d8f-1234-7890-abcd-ef1234567890", "01936d8f-5678-7890-abcd-ef1234567890" ], "targetParentId": "01936d8f-9abc-7890-abcd-ef1234567890" } ``` **批量删除**: ``` POST /api/v1/folders/batch/delete ``` **请求体**: ```json { "folderIds": [ "01936d8f-1234-7890-abcd-ef1234567890", "01936d8f-5678-7890-abcd-ef1234567890" ], "cascade": false } ``` **响应**: ```json { "success": [ "01936d8f-1234-7890-abcd-ef1234567890" ], "failed": [ { "folderId": "01936d8f-5678-7890-abcd-ef1234567890", "error": "没有权限删除此文件夹" } ] } ``` --- ## 相关数据表 ### 9.1 folder_shares 表(文件夹分享) ```sql 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 表(导出任务) ```sql 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` 表设计