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

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>
  );
}

优势

  1. 节省存储:不需要为每个用户创建2条文件夹记录
  2. 逻辑清晰:通过 folder_category 明确区分
  3. 自动继承:子文件夹自动继承父文件夹的分类
  4. 易于扩展:使用 SMALLINT 预留 3-255 用于未来扩展
  5. 前端灵活:虚拟根文件夹,用户体验不受影响

注意事项

  1. 查询根文件夹时必须指定 folder_category
  2. 创建根文件夹时必须指定 folder_category
  3. 子文件夹的 folder_category 由触发器自动继承,不能手动指定
  4. 前端需要处理虚拟根文件夹 ID 的转换
  5. 面包屑需要手动添加虚拟根层级

变更记录

v1.0 (2025-01-21)

  • 初始设计
  • 采用 SMALLINT + 代码枚举方案
  • 实现自动继承触发器
  • 提供完整的前后端实现示例