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.
 

12 KiB

Folder Tree API 完整修复

日期: 2026-02-06
类型: Bug 修复 + 功能增强
接口: GET /api/v1/folders/tree

问题描述

/api/v1/folders/tree 接口实现与文档规范不符:

  1. 参数默认值错误(include_projects 默认 False,应为 True
  2. 缺少 include_subprojects 参数
  3. 返回格式错误(项目在单独的 projects 字段,应在 children 数组)
  4. 缺少 type 字段标识节点类型
  5. 缺少子项目支持
  6. 根节点格式不符合规范
  7. projectCountsubfolderCount 始终返回 0(UUID 类型转换问题)

修复内容

1. API 路由层修复 (folders.py)

修复参数默认值和新增参数

@router.get("/tree")
async def get_folder_tree(
    max_depth: Optional[int] = Query(None, ge=1, description="最大深度"),
    include_projects: bool = Query(True, description="是否包含项目节点"),  # ✅ 默认 True
    include_subprojects: bool = Query(True, description="是否包含子项目节点"),  # ✅ 新增
    current_user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_session)
):
    service = FolderService(session)
    tree = await service.get_folder_tree(
        current_user.user_id, 
        max_depth, 
        include_projects,
        include_subprojects  # ✅ 传递新参数
    )
    return success_response(data=tree)

2. Service 层修复 (folder_service.py)

新增参数并修复返回格式

async def get_folder_tree(
    self,
    user_id: str,
    max_depth: Optional[int] = None,
    include_projects: bool = True,  # ✅ 默认 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
    }

3. Repository 层完整重构 (folder_repository.py)

修复核心逻辑

async def get_tree_structure(
    self,
    owner_id: str,
    parent_id: Optional[str] = None,
    max_depth: Optional[int] = None,
    current_depth: int = 0,
    include_projects: bool = True,  # ✅ 默认 True
    include_subprojects: bool = True  # ✅ 新增
) -> List[Dict[str, Any]]:
    """递归获取文件夹树形结构(包含项目和子项目)"""
    
    # ... 查询文件夹 ...
    
    for folder in folders:
        # 1. 文件夹节点(包含 type 字段)
        folder_node = {
            "id": str(folder.id),
            "name": folder.name,
            "type": "folder",  # ✅ 新增 type 字段
            "description": folder.description,
            "color": folder.color,
            "icon": folder.icon,
            "level": folder.level,
            "folderCategory": folder.folder_category,
            "projectCount": await self.count_projects(str(folder.id)),
            "subfolderCount": await self.count_subfolders(str(folder.id)),
            "children": children
        }
        
        # 2. 添加项目节点到 children 数组(与文件夹平级)
        if include_projects:
            projects = await self._get_projects_in_folder(str(folder.id), owner_id)
            
            for project in projects:
                project_node = {
                    "id": str(project.id),
                    "name": project.name,
                    "type": "project",  # ✅ 项目类型
                    "isSubproject": False,
                    "parentProjectId": None,
                    "screenplayId": None,
                    "children": []
                }
                
                # 3. 添加子项目(嵌套在父项目 children 中)
                if include_subprojects:
                    subprojects = await self._get_subprojects(str(project.id), owner_id)
                    
                    for subproject in subprojects:
                        subproject_node = {
                            "id": str(subproject.id),
                            "name": subproject.name,
                            "type": "subproject",  # ✅ 子项目类型
                            "isSubproject": True,
                            "parentProjectId": str(subproject.parent_project_id),
                            "screenplayId": str(subproject.screenplay_id) if subproject.screenplay_id else None,
                            "children": []
                        }
                        project_node["children"].append(subproject_node)
                
                folder_node["children"].append(project_node)  # ✅ 项目添加到 children
        
        tree.append(folder_node)
    
    return tree

修复统计方法

async def count_projects(self, folder_id: str) -> int:
    """统计文件夹内父项目数量(不包含子项目)"""
    from app.models.project import Project
    from uuid import UUID
    
    # ✅ 转换为 UUID 类型
    folder_uuid = UUID(folder_id) if isinstance(folder_id, str) else folder_id
    
    statement = select(func.count(Project.id)).where(
        Project.folder_id == folder_uuid,  # ✅ UUID 类型匹配
        Project.parent_project_id.is_(None),  # ✅ 只统计父项目
        Project.deleted_at.is_(None)
    )
    result = await self.session.exec(statement)
    return result.one()

async def count_subfolders(self, folder_id: str) -> int:
    """统计子文件夹数量"""
    from uuid import UUID
    
    # ✅ 转换为 UUID 类型
    folder_uuid = UUID(folder_id) if isinstance(folder_id, str) else folder_id
    
    statement = select(func.count(Folder.id)).where(
        Folder.parent_folder_id == folder_uuid,  # ✅ UUID 类型匹配
        Folder.deleted_at.is_(None)
    )
    result = await self.session.exec(statement)
    return result.one()

新增辅助方法

async def _get_projects_in_folder(self, folder_id: str, owner_id: str):
    """获取文件夹内的父项目(parent_project_id = NULL)"""
    from app.models.project import Project
    from uuid import UUID
    
    folder_uuid = UUID(folder_id) if isinstance(folder_id, str) else folder_id
    
    statement = select(Project).where(
        Project.folder_id == folder_uuid,
        Project.parent_project_id.is_(None),  # ✅ 只查父项目
        Project.deleted_at.is_(None)
    ).order_by(Project.display_order, Project.created_at)
    
    result = await self.session.exec(statement)
    return list(result.all())

async def _get_subprojects(self, parent_project_id: str, owner_id: str):
    """获取父项目的子项目"""
    from app.models.project import Project
    from uuid import UUID
    
    parent_uuid = UUID(parent_project_id) if isinstance(parent_project_id, str) else parent_project_id
    
    statement = select(Project).where(
        Project.parent_project_id == parent_uuid,  # ✅ 子项目条件
        Project.deleted_at.is_(None)
    ).order_by(Project.display_order, Project.created_at)
    
    result = await self.session.exec(statement)
    return list(result.all())

修复后的响应格式

请求示例

# 包含项目和子项目(默认)
GET /api/v1/folders/tree

# 仅包含文件夹
GET /api/v1/folders/tree?include_projects=false

# 包含项目但不包含子项目
GET /api/v1/folders/tree?include_projects=true&include_subprojects=false

# 限制深度
GET /api/v1/folders/tree?max_depth=3

响应格式(符合规范)

{
  "success": true,
  "code": 200,
  "message": "Success",
  "data": {
    "id": "root",
    "name": "根目录",
    "children": [
      {
        "id": "019c2d6e-8fc4-7c63-b875-8760c3221917",
        "name": "文件夹1",
        "type": "folder",
        "description": null,
        "color": "#3B82F6",
        "icon": null,
        "level": 0,
        "folderCategory": 1,
        "projectCount": 2,
        "subfolderCount": 1,
        "children": [
          {
            "id": "019c318e-49e6-7ee3-8c5d-5fc9c407eabd",
            "name": "子文件夹",
            "type": "folder",
            "level": 1,
            "projectCount": 0,
            "subfolderCount": 0,
            "children": []
          },
          {
            "id": "550e8400-e29b-41d4-a716-446655440001",
            "name": "电影项目A",
            "type": "project",
            "isSubproject": false,
            "parentProjectId": null,
            "screenplayId": null,
            "children": [
              {
                "id": "550e8400-e29b-41d4-a716-446655440002",
                "name": "第一集剧本",
                "type": "subproject",
                "isSubproject": true,
                "parentProjectId": "550e8400-e29b-41d4-a716-446655440001",
                "screenplayId": "550e8400-e29b-41d4-a716-446655440003",
                "children": []
              }
            ]
          }
        ]
      }
    ]
  },
  "timestamp": "2026-02-06T12:45:00+00:00"
}

关键改进

1. 树形结构统一

  • 文件夹、项目、子项目都使用 children 数组
  • 项目节点与文件夹节点平级
  • 子项目嵌套在父项目的 children

2. 节点类型标识

  • type: "folder" - 文件夹节点
  • type: "project" - 父项目节点
  • type: "subproject" - 子项目节点

3. 字段命名规范

  • 所有字段使用 camelCase(驼峰命名)
  • folderCategoryprojectCountsubfolderCount
  • isSubprojectparentProjectIdscreenplayId

4. 参数默认值

  • include_projects 默认 true
  • include_subprojects 默认 true
  • 符合文档规范

对比修复前后

项目 修复前 修复后
include_projects 默认值 False True
include_subprojects 参数 不存在 存在,默认 True
项目位置 node["projects"] node["children"]
type 字段 不存在 folder/project/subproject
子项目支持 不支持 完全支持
根节点格式 {"tree": [...]} {"id": "root", "name": "根目录", "children": [...]}
UUID 类型转换 不一致 统一字符串格式
projectCount 始终返回 0 正确统计父项目数量
subfolderCount UUID 类型不匹配 正确统计子文件夹数量

符合的文档规范

  • docs/requirements/backend/04-services/project/folder-service.md 第 5.2 节
  • .claude/skills/jointo-tech-stack/references/api-design.md 命名规范
  • API 响应字段使用 camelCase

测试建议

参数别名支持

API 现在同时支持 camelCase 和 snake_case 参数:

前端(camelCase) 后端(snake_case)
includeProjects include_projects
includeSubprojects include_subprojects
maxDepth max_depth

两种格式都可以正常使用!

测试用例

  1. 测试默认行为(应包含项目和子项目)

    curl "http://localhost:6170/api/v1/folders/tree" \
      -H "Authorization: Bearer YOUR_TOKEN"
    
  2. 测试仅文件夹

    curl "http://localhost:6170/api/v1/folders/tree?include_projects=false" \
      -H "Authorization: Bearer YOUR_TOKEN"
    
  3. 测试包含项目但不含子项目

    curl "http://localhost:6170/api/v1/folders/tree?include_projects=true&include_subprojects=false" \
      -H "Authorization: Bearer YOUR_TOKEN"
    
  4. 验证节点类型

    • 检查 type 字段是否正确:folder, project, subproject
    • 检查项目是否在 children 数组中
    • 检查子项目是否嵌套在父项目的 children

相关文件

  • server/app/api/v1/folders.py - API 路由层
  • server/app/services/folder_service.py - 业务逻辑层
  • server/app/repositories/folder_repository.py - 数据访问层
  • docs/requirements/backend/04-services/project/folder-service.md - 文档规范