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
70 KiB
文件夹管理服务
文档版本:v3.8
最后更新:2026-02-02
变更:完成代码实现,修复 Model 层、补充 Repository 层、创建数据库迁移文件
目录
服务概述
文件夹管理服务负责处理文件夹的创建、查询、移动、删除等业务逻辑,支持树形结构和权限管理。
职责
- 文件夹 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()函数),不使用数据库默认值 path和level字段由触发器自动计算,提升查询性能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) - 子项目节点包含
parentProjectId和screenplayId字段 - 前端根据
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 名称唯一性检查(应用层 + 数据库索引)
双重保护机制:
- 应用层主动检查(第一道防线):
# 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
- 数据库唯一索引(最后防线):
-- 已在表定义中创建部分唯一索引
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_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 函数:权限检查、循环引用检测、可访问文件夹查询改为应用层实现
- 保留触发器:自动更新时间戳、自动计算路径层级、自动继承分类
- 双重保护:应用层主动检查 + 数据库索引兜底
- UUID 生成:移除所有
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)
- 初始版本设计
相关文档
补充功能
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"
}
实现要点:
- 权限检查:需要源文件夹的 viewer 权限,目标位置的 editor 权限
- 名称处理:自动添加 "(副本)" 后缀,如果重名则添加数字 "(副本 2)"
- 递归克隆:
- 深度优先遍历子文件夹
- 保持原有的文件夹结构
- 项目克隆时重新生成 ID
- 资源文件需要复制(或共享引用)
- 异步处理:大文件夹克隆应使用后台任务,返回任务 ID
- 进度跟踪:支持查询克隆进度
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"
}
实现要点:
- 异步处理:使用后台任务处理导出
- 文件结构:
folder-name.zip ├── folder-name/ │ ├── project-1/ │ │ ├── project.json (项目元数据) │ │ ├── storyboards/ │ │ ├── resources/ │ │ └── timeline.json │ ├── project-2/ │ └── subfolder/ │ └── project-3/ └── manifest.json (导出清单) - 临时存储:导出文件存储在临时目录,24小时后自动清理
- 权限检查:需要文件夹的 viewer 权限
- 大小限制:单次导出不超过 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"
}
实现要点:
- 权限检查:需要文件夹的 owner 或 editor 权限
- 继承权限:子文件夹和项目自动继承分享权限
- 通知机制:分享给用户时发送通知
- 链接管理:支持撤销、更新分享链接
- 访问日志:记录分享链接的访问记录
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表设计