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.
17 KiB
17 KiB
RFC 123: 文件夹分类枚举设计
状态: Draft
创建日期: 2025-01-21
作者: System
相关文档: 文件夹服务文档
概述
为了避免为每个用户创建"我的项目"和"协作项目"两个系统文件夹,采用 SMALLINT + 代码枚举 的方式实现文件夹分类。
核心设计
- "我的项目"和"协作项目"是前端虚拟视图,不是数据库记录
- 使用
folder_category字段(SMALLINT 类型)区分文件夹所属分类 - 根文件夹(
parent_folder_id = NULL)必须指定folder_category - 子文件夹自动继承父文件夹的
folder_category - 前端负责虚拟显示"我的项目"和"协作项目"作为顶层导航
数据库设计
1. 添加 folder_category 字段
-- 添加文件夹分类字段
ALTER TABLE folders ADD COLUMN folder_category SMALLINT NOT NULL DEFAULT 1
CHECK (folder_category IN (1, 2));
-- 添加注释
COMMENT ON COLUMN folders.folder_category IS '文件夹分类:1=我的项目,2=协作项目';
-- 添加索引(复合索引,优化查询性能)
CREATE INDEX idx_folders_category_owner ON folders (folder_category, owner_id, parent_folder_id)
WHERE deleted_at IS NULL;
2. 自动继承触发器
-- 子文件夹自动继承父文件夹的 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;
-- 验证 folder_category 的有效性
IF NEW.folder_category NOT IN (1, 2) THEN
RAISE EXCEPTION '无效的 folder_category: %', NEW.folder_category;
END IF;
ELSE
-- 子文件夹自动继承父文件夹的 folder_category
SELECT folder_category INTO parent_category
FROM folders
WHERE id = NEW.parent_folder_id AND deleted_at IS NULL;
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();
3. 数据迁移脚本
-- 为现有文件夹设置默认分类(一次性迁移)
-- 假设现有的根文件夹都是"我的项目"
UPDATE folders
SET folder_category = 1
WHERE parent_folder_id IS NULL AND deleted_at IS NULL;
-- 子文件夹继承父文件夹的分类
WITH RECURSIVE folder_tree AS (
-- 根文件夹
SELECT id, folder_category, parent_folder_id
FROM folders
WHERE parent_folder_id IS NULL AND deleted_at IS NULL
UNION ALL
-- 递归获取子文件夹
SELECT f.id, ft.folder_category, f.parent_folder_id
FROM folders f
INNER JOIN folder_tree ft ON f.parent_folder_id = ft.id
WHERE f.deleted_at IS NULL
)
UPDATE folders f
SET folder_category = ft.folder_category
FROM folder_tree ft
WHERE f.id = ft.id AND f.parent_folder_id IS NOT NULL;
后端实现
1. 枚举定义
# app/models/folder.py
from enum import IntEnum
class FolderCategory(IntEnum):
"""文件夹分类枚举"""
MY_PROJECTS = 1 # 我的项目
COLLABORATIVE_PROJECTS = 2 # 协作项目
# 预留 3-255 用于未来扩展
@classmethod
def get_display_name(cls, value: int) -> str:
"""获取显示名称"""
names = {
cls.MY_PROJECTS: "我的项目",
cls.COLLABORATIVE_PROJECTS: "协作项目"
}
return names.get(value, "未知分类")
@classmethod
def is_valid(cls, value: int) -> bool:
"""验证分类值是否有效"""
return value in [cls.MY_PROJECTS, cls.COLLABORATIVE_PROJECTS]
2. Folder 模型
# app/models/folder.py
from sqlmodel import SQLModel, Field
from typing import Optional
from datetime import datetime
class Folder(SQLModel, table=True):
__tablename__ = "folders"
id: str = Field(primary_key=True)
name: str = Field(max_length=255)
description: Optional[str] = None
parent_folder_id: Optional[str] = Field(default=None, foreign_key='folders.id')
owner_id: str = Field(foreign_key='users.user_id')
# 路径信息
path: str = Field(default="/")
level: int = Field(default=0)
# 文件夹分类(SMALLINT)
folder_category: int = Field(default=FolderCategory.MY_PROJECTS)
# 排序
sort_order: int = Field(default=0)
# 样式
color: Optional[str] = None
icon: Optional[str] = None
cover_image_id: Optional[str] = None
# 共享
is_shared: bool = Field(default=False)
# 时间戳
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
deleted_at: Optional[datetime] = None
3. Schema 定义
# app/schemas/folder.py
from pydantic import BaseModel, Field, validator
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, description="文件夹分类:1=我的项目,2=协作项目")
color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$")
icon: Optional[str] = None
@validator('folder_category')
def validate_folder_category(cls, v, values):
# 如果是根文件夹(parent_folder_id 为 None),必须指定 folder_category
if values.get('parent_folder_id') is None and v is None:
raise ValueError('创建根文件夹时必须指定 folder_category')
return v
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")
folder_category_name: str = Field(alias="folderCategoryName")
sort_order: int = Field(alias="sortOrder")
color: Optional[str]
icon: Optional[str]
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")
class Config:
from_attributes = True
populate_by_name = True
4. Service 实现
# app/services/folder_service.py
from app.models.folder import FolderCategory
class FolderService:
async def get_folders(
self,
user_id: str,
parent_id: Optional[str] = None,
folder_category: Optional[int] = None,
page: int = 1,
page_size: int = 20
) -> Dict[str, Any]:
"""获取文件夹列表"""
# 如果查询根文件夹,必须指定 folder_category
if parent_id is None and folder_category is None:
raise ValidationError("查询根文件夹时必须指定 folder_category")
# 验证 folder_category
if folder_category is not None and not FolderCategory.is_valid(folder_category):
raise ValidationError(f"无效的 folder_category: {folder_category}")
# 查询文件夹
folders = await self.repository.get_by_parent(
parent_id, user_id, folder_category, page, page_size
)
# 构建响应
result = []
for folder in folders:
folder_data = {
"id": folder.id,
"name": folder.name,
"folderCategory": folder.folder_category,
"folderCategoryName": FolderCategory.get_display_name(folder.folder_category),
# ... 其他字段
}
result.append(folder_data)
return {"items": result, "total": len(result), ...}
async def create_folder(
self,
user_id: str,
folder_data: FolderCreate
) -> Folder:
"""创建文件夹"""
# 如果是根文件夹,必须指定 folder_category
if folder_data.parent_folder_id is None:
if folder_data.folder_category is None:
raise ValidationError("创建根文件夹时必须指定 folder_category")
if not FolderCategory.is_valid(folder_data.folder_category):
raise ValidationError(f"无效的 folder_category: {folder_data.folder_category}")
# 创建文件夹(子文件夹的 folder_category 由触发器自动继承)
folder = Folder(
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
)
return await self.repository.create(folder)
5. API 接口
# app/api/v1/folders.py
@router.get("", response_model=FolderListResponse)
async def get_folders(
parent_id: Optional[str] = Query(None, description="父文件夹 ID"),
folder_category: Optional[int] = Query(None, ge=1, le=2, description="文件夹分类:1=我的项目,2=协作项目"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)
):
"""
获取文件夹列表
- 查询根文件夹时必须指定 folder_category
- 查询子文件夹时 folder_category 自动继承
"""
service = FolderService(session)
return await service.get_folders(
current_user.user_id,
parent_id,
folder_category,
page,
page_size
)
@router.post("", response_model=FolderResponse)
async def create_folder(
folder_data: FolderCreate,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)
):
"""
创建文件夹
- 创建根文件夹时必须指定 folder_category
- 创建子文件夹时 folder_category 自动继承父文件夹
"""
service = FolderService(session)
folder = await service.create_folder(current_user.user_id, folder_data)
return {
"id": folder.id,
"name": folder.name,
"folderCategory": folder.folder_category,
"folderCategoryName": FolderCategory.get_display_name(folder.folder_category),
# ... 其他字段
}
前端实现
1. 类型定义
// client/src/types/folder.ts
export enum FolderCategory {
MY_PROJECTS = 1,
COLLABORATIVE_PROJECTS = 2
}
export const FOLDER_CATEGORY_NAMES = {
[FolderCategory.MY_PROJECTS]: '我的项目',
[FolderCategory.COLLABORATIVE_PROJECTS]: '协作项目'
};
export interface Folder {
id: string;
name: string;
description?: string;
parentFolderId?: string;
ownerId: string;
path: string;
level: number;
folderCategory: FolderCategory;
folderCategoryName: string;
sortOrder: number;
color?: string;
icon?: string;
isShared: boolean;
createdAt: string;
updatedAt: string;
subfolderCount?: number;
projectCount?: number;
}
export interface CreateFolderDto {
name: string;
description?: string;
parentFolderId?: string;
folderCategory?: FolderCategory; // 仅根文件夹需要
color?: string;
icon?: string;
}
2. 虚拟根文件夹
// client/src/constants/folder.ts
export const VIRTUAL_ROOTS = {
[FolderCategory.MY_PROJECTS]: {
id: 'virtual-mine',
name: '我的项目',
category: FolderCategory.MY_PROJECTS,
icon: 'folder',
color: '#3B82F6'
},
[FolderCategory.COLLABORATIVE_PROJECTS]: {
id: 'virtual-collab',
name: '协作项目',
category: FolderCategory.COLLABORATIVE_PROJECTS,
icon: 'users',
color: '#10B981'
}
} as const;
export const isVirtualRoot = (id: string): boolean => {
return id === 'virtual-mine' || id === 'virtual-collab';
};
export const getVirtualRootCategory = (id: string): FolderCategory | null => {
if (id === 'virtual-mine') return FolderCategory.MY_PROJECTS;
if (id === 'virtual-collab') return FolderCategory.COLLABORATIVE_PROJECTS;
return null;
};
3. API 调用
// client/src/services/api/folders.ts
export const foldersApi = {
// 获取文件夹列表
async getFolders(params: {
parentId?: string | null;
folderCategory?: FolderCategory;
page?: number;
pageSize?: number;
}): Promise<FolderListResponse> {
// 如果是虚拟根文件夹,转换为实际查询参数
let actualParentId = params.parentId;
let actualCategory = params.folderCategory;
if (isVirtualRoot(params.parentId)) {
actualParentId = null; // 查询根文件夹
actualCategory = getVirtualRootCategory(params.parentId);
}
const response = await apiClient.get('/folders', {
params: {
parent_id: actualParentId,
folder_category: actualCategory,
page: params.page || 1,
page_size: params.pageSize || 20
}
});
return transformKeys(response.data, toCamelCase);
},
// 创建文件夹
async createFolder(data: CreateFolderDto): Promise<Folder> {
// 如果父文件夹是虚拟根,转换为实际参数
let actualData = { ...data };
if (isVirtualRoot(data.parentFolderId)) {
actualData.parentFolderId = undefined; // 创建根文件夹
actualData.folderCategory = getVirtualRootCategory(data.parentFolderId);
}
const response = await apiClient.post('/folders', transformKeys(actualData, toSnakeCase));
return transformKeys(response.data, toCamelCase);
}
};
4. React Hooks
// client/src/hooks/api/useFolders.ts
export const useFolders = (parentId?: string | null, category?: FolderCategory) => {
return useQuery({
queryKey: ['folders', parentId, category],
queryFn: () => foldersApi.getFolders({ parentId, folderCategory: category }),
enabled: parentId !== undefined // 只有指定了 parentId 才查询
});
};
export const useCreateFolder = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: foldersApi.createFolder,
onSuccess: (newFolder) => {
// 使文件夹列表缓存失效
queryClient.invalidateQueries({ queryKey: ['folders'] });
// 如果是根文件夹,使对应分类的缓存失效
if (!newFolder.parentFolderId) {
queryClient.invalidateQueries({
queryKey: ['folders', null, newFolder.folderCategory]
});
}
}
});
};
5. 页面实现
// client/src/pages/ProjectsPage.tsx
export default function ProjectsPage() {
const [activeCategory, setActiveCategory] = useState<FolderCategory>(FolderCategory.MY_PROJECTS);
const [currentFolderId, setCurrentFolderId] = useState<string | null>('virtual-mine');
// 获取文件夹列表
const { data: foldersData } = useFolders(currentFolderId, activeCategory);
// 处理根视图切换
const handleRootChange = (category: FolderCategory) => {
setActiveCategory(category);
setCurrentFolderId(category === FolderCategory.MY_PROJECTS ? 'virtual-mine' : 'virtual-collab');
};
// 处理创建文件夹
const handleCreateFolder = () => {
setCreateFolderModalOpen(true, {
parentId: currentFolderId, // 可能是虚拟根 ID
category: activeCategory
});
};
// 面包屑处理
const breadcrumbs = useMemo(() => {
const crumbs = [];
// 添加虚拟根
const virtualRoot = VIRTUAL_ROOTS[activeCategory];
crumbs.push(virtualRoot);
// 添加实际文件夹路径
if (currentFolderId && !isVirtualRoot(currentFolderId)) {
// ... 构建实际路径
}
return crumbs;
}, [currentFolderId, activeCategory]);
return (
<div>
<ProjectSidebar
activeCategory={activeCategory}
onCategoryChange={handleRootChange}
/>
{/* ... */}
</div>
);
}
优势
- 节省存储:不需要为每个用户创建2条文件夹记录
- 逻辑清晰:通过
folder_category明确区分 - 自动继承:子文件夹自动继承父文件夹的分类
- 易于扩展:使用 SMALLINT 预留 3-255 用于未来扩展
- 前端灵活:虚拟根文件夹,用户体验不受影响
注意事项
- 查询根文件夹时必须指定 folder_category
- 创建根文件夹时必须指定 folder_category
- 子文件夹的 folder_category 由触发器自动继承,不能手动指定
- 前端需要处理虚拟根文件夹 ID 的转换
- 面包屑需要手动添加虚拟根层级
变更记录
v1.0 (2025-01-21)
- 初始设计
- 采用 SMALLINT + 代码枚举方案
- 实现自动继承触发器
- 提供完整的前后端实现示例