# 子项目架构设计 > **文档版本**:v1.3 > **最后更新**:2026-02-02 > **变更**:优化剧本创建流程,先创建子项目再创建剧本,使用事务保证数据一致性 --- ## 目录 1. [架构概述](#架构概述) 2. [数据模型](#数据模型) 3. [业务流程](#业务流程) 4. [前端展示](#前端展示) 5. [API 设计](#api-设计) 6. [权限继承](#权限继承) 7. [使用场景](#使用场景) --- ## 架构概述 ### 设计目标 支持项目层级结构,实现"父项目 → 子项目 → 分镜"的三层架构,满足以下需求: 1. **剧本关联**:每个剧本自动创建对应的子项目 2. **独立管理**:子项目可以独立管理分镜、资源、导出等功能 3. **权限继承**:子项目继承父项目的权限设置 4. **文件夹树展示**:在文件夹树中清晰展示父子项目关系 ### 架构层级 ``` 📁 文件夹 (folders) └── 📂 父项目 (projects, parent_project_id = NULL) └── 📄 子项目 (projects, parent_project_id = 父项目ID) ├── 📜 剧本 (screenplays, project_id = 子项目ID) └── 🎬 分镜 (storyboards, project_id = 子项目ID) ``` --- ## 数据模型 ### 父子项目关系 **父项目**: ```sql project_id: "019d1234-5678-7abc-def0-111111111111" name: "电影项目A" parent_project_id: NULL -- 根项目 screenplay_id: NULL -- 父项目不关联剧本 folder_id: "019d1234-5678-7abc-def0-000000000000" ``` **子项目**: ```sql project_id: "019d1234-5678-7abc-def0-222222222222" name: "第一集剧本" parent_project_id: "019d1234-5678-7abc-def0-111111111111" -- 指向父项目 screenplay_id: "019d1234-5678-7abc-def0-333333333333" -- 关联剧本(一对一) folder_id: "019d1234-5678-7abc-def0-000000000000" -- 继承父项目的文件夹 ``` **剧本**: ```sql screenplay_id: "019d1234-5678-7abc-def0-333333333333" project_id: "019d1234-5678-7abc-def0-222222222222" -- 指向子项目 name: "第一集剧本" ``` **分镜**: ```sql storyboard_id: "019d1234-5678-7abc-def0-444444444444" project_id: "019d1234-5678-7abc-def0-222222222222" -- 指向子项目 screenplay_id: "019d1234-5678-7abc-def0-333333333333" -- 关联剧本 ``` ### 数据库约束 ```sql -- 子项目约束:子项目必须关联剧本 ALTER TABLE projects ADD CONSTRAINT projects_subproject_screenplay_check CHECK ( (parent_project_id IS NULL AND screenplay_id IS NULL) OR (parent_project_id IS NOT NULL AND screenplay_id IS NOT NULL) ); -- 索引 CREATE INDEX idx_projects_parent_project_id ON projects (parent_project_id) WHERE parent_project_id IS NOT NULL AND status IN (0, 1); CREATE INDEX idx_projects_screenplay_id ON projects (screenplay_id) WHERE screenplay_id IS NOT NULL; ``` --- ## 业务流程 ### 1. 创建剧本并自动创建子项目 **用户操作**: ``` 用户在父项目中 → 点击"创建剧本" → 选择"上传文件"或"粘贴文本" → 提交 ``` **时序图**: ```mermaid sequenceDiagram actor User as 用户 participant Frontend as 前端 participant API as ScreenplayAPI participant ScreenplayService as ScreenplayService participant ProjectService as ProjectService participant FileStorage as FileStorageService participant Converter as 格式转换器 participant DB as 数据库 User->>Frontend: 点击"创建剧本" Frontend->>User: 显示创建表单
(支持文件上传/文本粘贴) User->>Frontend: 提交内容 alt 文件上传模式 Frontend->>API: POST /api/v1/screenplays
(multipart/form-data)
{project_id, name, file} else 文本粘贴模式 Frontend->>API: POST /api/v1/screenplays
(application/json)
{project_id, name, content} end API->>ScreenplayService: create_screenplay() Note over ScreenplayService: 步骤1: 权限检查 ScreenplayService->>DB: 检查用户对父项目的权限 DB-->>ScreenplayService: 权限验证通过 Note over ScreenplayService: 步骤2: 格式转换为Markdown alt 文件上传 ScreenplayService->>Converter: convert_to_markdown(file) Converter->>Converter: TXT/DOC/DOCX/PDF/RTF → Markdown Converter-->>ScreenplayService: 返回Markdown内容 else 文本粘贴 ScreenplayService->>Converter: text_to_markdown(content) Converter-->>ScreenplayService: 返回Markdown内容 end Note over ScreenplayService: 步骤3: 上传Markdown文件(事务外) ScreenplayService->>FileStorage: upload_file(markdown_content) FileStorage->>FileStorage: 生成UUID文件名 FileStorage->>FileStorage: 保存到MinIO (.md) FileStorage-->>ScreenplayService: 返回文件URL rect rgb(240, 248, 255) Note over ScreenplayService,DB: 开启数据库事务 Note over ScreenplayService: 步骤4: 先创建子项目(screenplay_id=NULL) ScreenplayService->>ProjectService: create_subproject()
{parent_project_id, name} ProjectService->>DB: 查询父项目信息 DB-->>ProjectService: 返回父项目数据 Note over ProjectService: 继承父项目属性 ProjectService->>DB: INSERT INTO projects
(parent_project_id, screenplay_id=NULL, ...) DB-->>ProjectService: 返回subproject_id alt 父项目是协作项目 ProjectService->>DB: 查询父项目成员 DB-->>ProjectService: 返回成员列表 loop 复制每个成员 ProjectService->>DB: INSERT INTO project_members
(project_id=subproject_id, ...) end end ProjectService-->>ScreenplayService: 返回子项目信息 Note over ScreenplayService: 步骤5: 创建剧本(直接关联子项目) ScreenplayService->>DB: INSERT INTO screenplays
(project_id=subproject_id, type=file, ...) DB-->>ScreenplayService: 返回screenplay_id Note over ScreenplayService: 步骤6: 更新子项目的screenplay_id ScreenplayService->>DB: UPDATE projects
SET screenplay_id=screenplay_id
WHERE project_id=subproject_id DB-->>ScreenplayService: 更新成功 Note over ScreenplayService,DB: 提交事务(保证原子性) end ScreenplayService-->>API: 返回{screenplay, subproject} API-->>Frontend: 200 OK
{success: true, data: {...}} Frontend->>Frontend: 更新文件夹树 Frontend->>User: 显示成功提示
跳转到子项目页面 Note over ScreenplayService: 错误处理:如果事务失败 alt 事务失败 ScreenplayService->>DB: 回滚事务 ScreenplayService->>FileStorage: 删除已上传的文件 ScreenplayService-->>API: 返回错误信息 API-->>Frontend: 500 Error Frontend->>User: 显示错误提示 end ``` **后端处理流程**: ```python # 1. 创建剧本(支持两种模式) # 模式1: 文件上传 POST /api/v1/screenplays Content-Type: multipart/form-data { "project_id": "parent-project-id", # 父项目ID "name": "第一集剧本", "file": # TXT/DOC/DOCX/PDF/RTF/Markdown } # 模式2: 文本粘贴 POST /api/v1/screenplays Content-Type: application/json { "project_id": "parent-project-id", # 父项目ID "name": "第一集剧本", "content": "场景1:室内 - 咖啡厅 - 白天\n\n张三坐在窗边..." } # 2. ScreenplayService 处理(优化后的流程) async def create_screenplay(...): # 2.1 检查父项目权限 await self._check_project_permission(user_id, project_id, 'editor') # 2.2 格式转换为Markdown if file: markdown_content = await self._convert_file_to_markdown(file) else: markdown_content = await self._convert_text_to_markdown(content) # 2.3 上传Markdown文件到对象存储(事务外完成,避免长事务) try: file_meta = await self.file_storage.upload_file( file_content=markdown_content.encode('utf-8'), filename=f"{name}.md", content_type='text/markdown', category='screenplay', user_id=user_id ) except Exception as e: raise HTTPException(500, "文件上传失败: %s" % str(e)) # 2.4 开启数据库事务,保证数据一致性 async with self.db.begin(): try: # 2.4.1 先创建子项目(screenplay_id=NULL) subproject = await project_service.create_subproject( user_id=user_id, parent_project_id=project_id, name=name, description=f"基于剧本《{name}》的制作项目" ) # 2.4.2 创建剧本记录(直接关联子项目) screenplay = Screenplay( project_id=subproject.project_id, # 直接关联子项目 name=name, type=ScreenplayType.FILE, # 统一为文件类型 file_url=file_meta.file_url, mime_type='text/markdown', # 统一为Markdown ... ) screenplay = await self.repository.create(screenplay) # 2.4.3 更新子项目的 screenplay_id await project_service.update_screenplay_id( subproject.project_id, screenplay.screenplay_id ) # 事务提交 return { 'screenplay': screenplay, 'subproject': subproject } except Exception as e: # 事务自动回滚 # 清理已上传的文件 await self.file_storage.delete_file(file_meta.file_url) raise HTTPException(500, "创建剧本失败: %s" % str(e)) # 3. ProjectService 创建子项目(screenplay_id 稍后更新) async def create_subproject(...): # 3.1 创建子项目(screenplay_id 暂时为 NULL) subproject = Project( name=name, type=parent_project.type, # 继承父项目类型 owner_id=parent_project.owner_id, # 继承所有者 folder_id=parent_project.folder_id, # 继承文件夹 parent_project_id=parent_project_id, screenplay_id=None, # 稍后更新 settings=parent_project.settings.copy() ) # 3.2 如果父项目是协作项目,复制成员权限 if parent_project.type == 'collab': members = await self.repository.get_members(parent_project_id) for member in members: await self.repository.add_member( subproject.id, member['user_id'], member['role'] ) return subproject ``` **统一响应**: ```json { "success": true, "message": "剧本创建成功", "data": { "screenplay": { "screenplay_id": "019d1234-5678-7abc-def0-333333333333", "project_id": "019d1234-5678-7abc-def0-222222222222", // 子项目ID "name": "第一集剧本", "type": "file", "file_url": "https://storage.jointo.ai/screenplays/abc123.md", "mime_type": "text/markdown", "version": 1, "status": "draft" }, "subproject": { "project_id": "019d1234-5678-7abc-def0-222222222222", "name": "第一集剧本", "parent_project_id": "019d1234-5678-7abc-def0-111111111111", "screenplay_id": "019d1234-5678-7abc-def0-333333333333", "type": "mine", "description": "基于剧本《第一集剧本》的制作项目" } } } ``` **业务规则**: 1. **格式转换**: - 文件上传:TXT/DOC/DOCX/PDF/RTF/Markdown → 统一转换为 Markdown - 文本粘贴:直接将文本内容转换为 Markdown 格式 - 转换后的 Markdown 文件存储到对象存储(MinIO/OSS) 2. **子项目创建(优化后的流程)**: - 文件上传在**事务外**完成(避免长事务) - **先创建子项目**(screenplay_id=NULL) - **再创建剧本**(project_id 直接指向子项目) - **最后更新子项目**的 screenplay_id - 整个数据库操作在**事务内**完成,保证原子性 - 子项目名称与剧本同名 - 子项目继承父项目的类型(mine/collab)和权限设置 3. **文件存储**: - 原始文件(如有)保留备份 - 返回的 `file_url` 指向转换后的 `.md` 文件 - `mime_type` 统一为 `text/markdown` - 剧本类型统一为 `file`(即使是文本粘贴) 4. **错误处理与回滚**: - 文件格式不支持 → 400 错误 - 文件上传失败 → 500 错误(不进入事务) - 数据库操作失败 → 自动回滚事务 + 删除已上传的文件 - 父项目不存在 → 404 错误 - 用户无权限 → 403 错误 5. **数据一致性保证**: - 使用数据库事务确保"创建子项目 → 创建剧本 → 更新关联"的原子性 - 任何步骤失败都会回滚,不会出现数据不一致 - 文件上传失败不会创建数据库记录 - 数据库操作失败会清理已上传的文件 ### 2. 查询子项目列表 **API 调用**: ``` GET /api/v1/projects/{parent_project_id}/subprojects?page=1&page_size=20 ``` **响应**: ```json { "success": true, "data": { "items": [ { "project_id": "019d1234-5678-7abc-def0-222222222222", "name": "第一集剧本", "parent_project_id": "019d1234-5678-7abc-def0-111111111111", "screenplay_id": "019d1234-5678-7abc-def0-333333333333", "type": "mine", "created_at": "2025-01-27T10:00:00Z" }, { "project_id": "019d1234-5678-7abc-def0-555555555555", "name": "第二集剧本", "parent_project_id": "019d1234-5678-7abc-def0-111111111111", "screenplay_id": "019d1234-5678-7abc-def0-666666666666", "type": "mine", "created_at": "2025-01-28T10:00:00Z" } ], "total": 2, "page": 1, "page_size": 20 } } ``` ### 3. 删除子项目 **级联删除规则**: 1. **删除子项目**: - 子项目移至回收站(status=2) - 关联的剧本移至回收站 - 关联的分镜移至回收站 - 关联的资源保留(可选择是否删除) 2. **删除父项目**: - 父项目移至回收站 - 所有子项目移至回收站 - 所有剧本移至回收站 - 所有分镜移至回收站 **时序图**: ```mermaid sequenceDiagram actor User as 用户 participant Frontend as 前端 participant API as ProjectAPI participant ProjectService as ProjectService participant ScreenplayService as ScreenplayService participant StoryboardService as StoryboardService participant DB as 数据库 User->>Frontend: 点击"删除子项目" Frontend->>User: 显示确认对话框 User->>Frontend: 确认删除 Frontend->>API: DELETE /api/v1/projects/{subproject_id} API->>ProjectService: delete_project(subproject_id) Note over ProjectService: 步骤1: 权限检查 ProjectService->>DB: 检查用户权限 DB-->>ProjectService: 权限验证通过 Note over ProjectService: 步骤2: 查询子项目信息 ProjectService->>DB: SELECT * FROM projects
WHERE project_id=subproject_id DB-->>ProjectService: 返回子项目数据
{screenplay_id, ...} Note over ProjectService: 步骤3: 删除关联的分镜 ProjectService->>StoryboardService: soft_delete_by_project(subproject_id) StoryboardService->>DB: UPDATE storyboards
SET status=2, deleted_at=NOW()
WHERE project_id=subproject_id DB-->>StoryboardService: 更新成功 StoryboardService-->>ProjectService: 删除完成 Note over ProjectService: 步骤4: 删除关联的剧本 ProjectService->>ScreenplayService: soft_delete(screenplay_id) ScreenplayService->>DB: UPDATE screenplays
SET status=2, deleted_at=NOW()
WHERE screenplay_id=... DB-->>ScreenplayService: 更新成功 ScreenplayService-->>ProjectService: 删除完成 Note over ProjectService: 步骤5: 删除子项目 ProjectService->>DB: UPDATE projects
SET status=2, deleted_at=NOW()
WHERE project_id=subproject_id DB-->>ProjectService: 更新成功 ProjectService-->>API: 删除成功 API-->>Frontend: 200 OK
{success: true} Frontend->>Frontend: 从文件夹树移除节点 Frontend->>User: 显示成功提示
"已移至回收站" ``` **API 调用**: ``` DELETE /api/v1/projects/{subproject_id} ``` --- ## 前端展示 ### 文件夹树结构 ``` 📁 我的项目文件夹 📂 电影项目A (父项目) 📄 第一集剧本 (子项目) └── 分镜1, 分镜2, 分镜3 📄 第二集剧本 (子项目) └── 分镜1, 分镜2 📂 广告项目B (父项目) 📄 15秒版本 (子项目) 📄 30秒版本 (子项目) ``` ### 前端实现逻辑 **1. 获取文件夹树数据**: ```typescript // 1. 获取文件夹列表 GET /api/v1/folders?user_id={user_id} // 2. 获取根项目列表(parent_project_id = NULL) GET /api/v1/projects?folder_id={folder_id}&parent_project_id=null // 3. 获取子项目列表 GET /api/v1/projects/{parent_project_id}/subprojects ``` **2. 构建树形结构**: ```typescript interface TreeNode { id: string; name: string; type: 'folder' | 'project' | 'subproject'; parentId?: string; children?: TreeNode[]; meta_data?: { projectId?: string; screenplayId?: string; isSubproject?: boolean; }; } // 构建树 const buildTree = (folders, projects, subprojects) => { const tree: TreeNode[] = []; // 1. 添加文件夹节点 folders.forEach(folder => { tree.push({ id: folder.folder_id, name: folder.name, type: 'folder', children: [] }); }); // 2. 添加根项目节点 projects.forEach(project => { const folderNode = tree.find(n => n.id === project.folder_id); if (folderNode) { folderNode.children.push({ id: project.project_id, name: project.name, type: 'project', children: [], meta_data: { projectId: project.project_id } }); } }); // 3. 添加子项目节点 subprojects.forEach(subproject => { const parentNode = findNodeById(tree, subproject.parent_project_id); if (parentNode) { parentNode.children.push({ id: subproject.project_id, name: subproject.name, type: 'subproject', meta_data: { projectId: subproject.project_id, screenplayId: subproject.screenplay_id, isSubproject: true } }); } }); return tree; }; ``` **3. 节点点击事件**: **时序图**: ```mermaid sequenceDiagram actor User as 用户 participant Frontend as 前端 participant API as ProjectAPI participant DB as 数据库 User->>Frontend: 点击父项目节点 Frontend->>Frontend: handleNodeClick(node) Note over Frontend: 判断节点类型为'project' Frontend->>Frontend: redirectToFirstSubproject(projectId) Frontend->>API: GET /api/v1/projects/{parent_project_id}/subprojects
?page=1&page_size=1 API->>DB: SELECT * FROM projects
WHERE parent_project_id=...
ORDER BY display_order
LIMIT 1 alt 有子项目 DB-->>API: 返回第一个子项目 API-->>Frontend: {items: [{project_id, name, ...}]} Frontend->>Frontend: router.push(`/projects/${subproject_id}`) Frontend->>User: 显示子项目详情页 else 没有子项目 DB-->>API: 返回空列表 API-->>Frontend: {items: []} Frontend->>Frontend: router.push(`/projects/${parent_project_id}`) Frontend->>User: 显示父项目详情页 end ``` **代码实现**: ```typescript const handleNodeClick = async (node: TreeNode) => { switch (node.type) { case 'folder': // 展开/折叠文件夹 toggleFolder(node.id); break; case 'project': // 点击父项目时,自动进入第一个子项目 await redirectToFirstSubproject(node.meta_data.projectId); break; case 'subproject': // 跳转到子项目详情页(界面与父项目一致) router.push(`/projects/${node.meta_data.projectId}`); break; } }; /** * 重定向到父项目的第一个子项目 * 如果没有子项目,则进入父项目本身 */ const redirectToFirstSubproject = async (parentProjectId: string) => { try { // 获取子项目列表(按 display_order 排序) const response = await fetch( `/api/v1/projects/${parentProjectId}/subprojects?page=1&page_size=1` ); const data = await response.json(); if (data.success && data.data.items.length > 0) { // 有子项目,跳转到第一个子项目 const firstSubproject = data.data.items[0]; router.push(`/projects/${firstSubproject.project_id}`); } else { // 没有子项目,跳转到父项目本身 router.push(`/projects/${parentProjectId}`); } } catch (error) { console.error('Failed to load subprojects:', error); // 出错时跳转到父项目 router.push(`/projects/${parentProjectId}`); } }; ``` ### 项目详情页 **说明**: - 子项目的界面与父项目完全一致,使用相同的项目详情页组件 - 页面根据 `is_subproject` 字段判断是否为子项目 - 如果是子项目,面包屑导航显示:`文件夹 > 父项目 > 子项目` - 如果是父项目,面包屑导航显示:`文件夹 > 父项目` - 页面内容包括:项目信息、剧本内容、分镜列表、资源管理等 **面包屑导航示例**: ```typescript // 获取项目详情 const project = await getProject(projectId); // 构建面包屑 const breadcrumbs = []; // 添加文件夹 if (project.folder_id) { const folderPath = await getFolderPath(project.folder_id); breadcrumbs.push(...folderPath); } // 添加父项目 if (project.is_subproject && project.parent_project_id) { const parentProject = await getProject(project.parent_project_id); breadcrumbs.push({ id: parentProject.project_id, name: parentProject.name, type: 'project' }); } // 添加当前项目 breadcrumbs.push({ id: project.project_id, name: project.name, type: project.is_subproject ? 'subproject' : 'project' }); ``` **渲染效果**: ``` 我的项目文件夹 > 电影项目A > 第一集剧本 ``` --- ## API 设计 ### 1. 创建剧本并自动创建子项目 ``` POST /api/v1/screenplays ``` 支持两种创建模式:**文件上传**和**文本粘贴**。无论哪种方式,后端都会将内容转换为 Markdown 格式存储,并强制创建子项目。 --- #### 模式 1:文件上传 **请求**(`Content-Type: multipart/form-data`): - `project_id`: 父项目 ID(必填) - `name`: 剧本名称(必填) - `file`: 剧本文件(必填,支持 TXT、DOC、DOCX、PDF、RTF、Markdown) **示例**: ```bash curl -X POST "https://api.jointo.ai/api/v1/screenplays" \ -H "Authorization: Bearer {token}" \ -F "project_id=019d1234-5678-7abc-def0-111111111111" \ -F "name=第一集剧本" \ -F "file=@screenplay.pdf" ``` --- #### 模式 2:文本粘贴 **请求**(`Content-Type: application/json`): ```json { "project_id": "019d1234-5678-7abc-def0-111111111111", "name": "第一集剧本", "content": "场景1:室内 - 咖啡厅 - 白天\n\n张三坐在窗边..." } ``` **参数说明**: - `project_id`: 父项目 ID(必填) - `name`: 剧本名称(必填) - `content`: 剧本文本内容(必填) --- #### 统一响应 ```json { "success": true, "message": "剧本创建成功", "data": { "screenplay": { "screenplay_id": "019d1234-5678-7abc-def0-222222222222", "project_id": "019d1234-5678-7abc-def0-333333333333", "name": "第一集剧本", "type": "file", "file_url": "https://storage.jointo.ai/screenplays/abc123.md", "file_size": 1024000, "mime_type": "text/markdown", "checksum": "abc123...", "version": 1, "status": "draft", "created_at": "2025-01-27T10:00:00Z" }, "subproject": { "project_id": "019d1234-5678-7abc-def0-333333333333", "name": "第一集剧本", "parent_project_id": "019d1234-5678-7abc-def0-111111111111", "screenplay_id": "019d1234-5678-7abc-def0-222222222222", "type": "mine", "description": "基于剧本《第一集剧本》的制作项目", "created_at": "2025-01-27T10:00:00Z" } } } ``` --- #### 业务规则 1. **格式转换**: - 文件上传:TXT/DOC/DOCX/PDF/RTF/Markdown → 统一转换为 Markdown - 文本粘贴:直接将文本内容转换为 Markdown 格式 - 转换后的 Markdown 文件存储到对象存储(MinIO/OSS) 2. **子项目创建(优化后的流程)**: - 文件上传在**事务外**完成(避免长事务) - **先创建子项目**(screenplay_id=NULL) - **再创建剧本**(project_id 直接指向子项目) - **最后更新子项目**的 screenplay_id - 整个数据库操作在**事务内**完成,保证原子性 - 子项目名称与剧本同名 - 子项目继承父项目的类型(mine/collab)和权限设置 3. **文件存储**: - 原始文件(如有)保留备份 - 返回的 `file_url` 指向转换后的 `.md` 文件 - `mime_type` 统一为 `text/markdown` - 剧本类型统一为 `file`(即使是文本粘贴) 4. **错误处理与回滚**: - 文件格式不支持 → 400 错误 - 文件上传失败 → 500 错误(不进入事务) - 数据库操作失败 → 自动回滚事务 + 删除已上传的文件 - 父项目不存在 → 404 错误 - 用户无权限 → 403 错误 5. **数据一致性保证**: - 使用数据库事务确保"创建子项目 → 创建剧本 → 更新关联"的原子性 - 任何步骤失败都会回滚,不会出现数据不一致 - 文件上传失败不会创建数据库记录 - 数据库操作失败会清理已上传的文件 ### 2. 获取子项目列表 ``` GET /api/v1/projects/{parent_project_id}/subprojects ``` **查询参数**: - `page`: 页码 - `page_size`: 每页数量 **响应**: ```json { "success": true, "data": { "items": [ ... ], "total": 10, "page": 1, "page_size": 20 } } ``` ### 3. 获取子项目详情 ``` GET /api/v1/projects/{subproject_id} ``` **响应**: ```json { "success": true, "data": { "project_id": "019d1234-5678-7abc-def0-222222222222", "name": "第一集剧本", "parent_project_id": "019d1234-5678-7abc-def0-111111111111", "screenplay_id": "019d1234-5678-7abc-def0-333333333333", "is_subproject": true, "type": "mine", "created_at": "2025-01-27T10:00:00Z" } } ``` ### 4. 删除子项目 ``` DELETE /api/v1/projects/{subproject_id} ``` **说明**: - 子项目移至回收站 - 关联的剧本和分镜也移至回收站 - 30天内可恢复 --- ## 权限继承 ### 继承规则 1. **项目类型继承**: - 子项目的 `type` 继承父项目(mine/collab) 2. **所有者继承**: - 子项目的 `owner_id` 和 `owner_type` 继承父项目 3. **文件夹继承**: - 子项目的 `folder_id` 继承父项目 4. **成员权限继承**(协作项目): - 如果父项目是协作项目(collab),子项目自动复制父项目的成员和权限 - 父项目的 owner → 子项目的 owner - 父项目的 editor → 子项目的 editor - 父项目的 viewer → 子项目的 viewer 5. **项目设置继承**: - 子项目的 `settings` 复制父项目的配置 ### 权限检查逻辑 ```python async def check_subproject_permission( user_id: UUID, subproject_id: UUID, required_role: str ) -> bool: """检查子项目权限""" # 1. 检查子项目直接权限 has_direct_permission = await self.repository.check_user_permission( user_id, subproject_id, required_role ) if has_direct_permission: return True # 2. 检查父项目权限(权限继承) subproject = await self.repository.get_by_id(subproject_id) if subproject.parent_project_id: has_parent_permission = await self.repository.check_user_permission( user_id, subproject.parent_project_id, required_role ) if has_parent_permission: return True return False ``` --- ## 使用场景 ### 场景 1:电影项目 ``` 📂 电影《西游记》(父项目) 📄 第一集:大闹天宫 (子项目) └── 分镜1-50 📄 第二集:三打白骨精 (子项目) └── 分镜1-40 📄 第三集:真假美猴王 (子项目) └── 分镜1-45 ``` ### 场景 2:广告项目 ``` 📂 品牌广告2025 (父项目) 📄 15秒版本 (子项目) └── 分镜1-10 📄 30秒版本 (子项目) └── 分镜1-20 📄 60秒版本 (子项目) └── 分镜1-40 ``` ### 场景 3:剧集项目 ``` 📂 电视剧《三体》(父项目) 📄 第一季第一集 (子项目) └── 分镜1-100 📄 第一季第二集 (子项目) └── 分镜1-95 📄 第一季第三集 (子项目) └── 分镜1-98 ``` --- ## 技术实现要点 ### 1. 数据库迁移 ```sql -- 添加父子项目字段 ALTER TABLE projects ADD COLUMN parent_project_id UUID; ALTER TABLE projects ADD COLUMN screenplay_id UUID; -- 添加约束 ALTER TABLE projects ADD CONSTRAINT projects_subproject_screenplay_check CHECK ( (parent_project_id IS NULL AND screenplay_id IS NULL) OR (parent_project_id IS NOT NULL AND screenplay_id IS NOT NULL) ); -- 添加索引 CREATE INDEX idx_projects_parent_project_id ON projects (parent_project_id) WHERE parent_project_id IS NOT NULL AND status IN (0, 1); CREATE INDEX idx_projects_screenplay_id ON projects (screenplay_id) WHERE screenplay_id IS NOT NULL; -- 添加列注释 COMMENT ON COLUMN projects.parent_project_id IS '父项目ID,NULL表示根项目'; COMMENT ON COLUMN projects.screenplay_id IS '关联的剧本ID(仅子项目使用)'; ``` ### 2. 服务层实现 **ProjectService**: - 新增 `create_subproject()` 方法 - 新增 `get_subprojects()` 方法 - 修改 `delete_project()` 方法(级联删除子项目) **ScreenplayService**: - 修改 `create_screenplay_from_file()` 方法(自动创建子项目) - 新增 `auto_create_subproject` 参数 ### 3. 前端实现 **文件夹树组件**: - 支持三层结构:文件夹 → 父项目 → 子项目 - 懒加载子项目列表 - 拖拽排序支持 - 点击父项目时,自动重定向到第一个子项目 **项目详情页**: - 父项目和子项目使用相同的页面组件 - 根据 `is_subproject` 字段区分显示逻辑 - 面包屑导航自动显示完整路径 - 支持剧本内容、分镜列表、资源管理、导出、分享等功能 --- ## 测试 ### 单元测试 ```bash # 运行子项目相关单元测试 docker exec jointo-server-app pytest tests/unit/services/test_screenplay_service.py::test_create_screenplay_with_subproject -v ``` ### 集成测试 ```bash # 运行子项目创建集成测试 docker exec jointo-server-app pytest tests/integration/services/test_subproject_creation.py -v # 测试剧本上传并创建子项目 docker exec jointo-server-app pytest tests/integration/services/test_screenplay_upload.py -v ``` ### 测试覆盖率 ```bash # 生成测试覆盖率报告 docker exec jointo-server-app pytest tests/ --cov=app.services.screenplay_service --cov-report=html ``` --- ## 数据库迁移 ### 创建迁移文件 ```bash # 1. 添加父子项目字段(如果尚未添加) docker exec jointo-server-app alembic revision -m "add_parent_project_and_screenplay_fields" # 2. 编辑迁移文件,添加以下 SQL: # ALTER TABLE projects ADD COLUMN parent_project_id UUID; # ALTER TABLE projects ADD COLUMN screenplay_id UUID; # # ALTER TABLE projects ADD CONSTRAINT projects_subproject_screenplay_check # CHECK ( # (parent_project_id IS NULL AND screenplay_id IS NULL) OR # (parent_project_id IS NOT NULL AND screenplay_id IS NOT NULL) # ); # # CREATE INDEX idx_projects_parent_project_id ON projects (parent_project_id) # WHERE parent_project_id IS NOT NULL AND status IN (0, 1); # # CREATE INDEX idx_projects_screenplay_id ON projects (screenplay_id) # WHERE screenplay_id IS NOT NULL; # 3. 执行迁移 docker exec jointo-server-app python scripts/db_migrate.py upgrade # 4. 验证迁移 docker exec jointo-server-app alembic history ``` ### 回滚迁移 ```bash # 回滚到上一个版本 docker exec jointo-server-app python scripts/db_migrate.py downgrade -1 ``` --- ## 相关文档 - [项目管理服务](./project-service.md) - [剧本管理服务](./screenplay-service.md) - [分镜管理服务](./storyboard-service.md) --- ## 变更记录 ### v1.3 (2026-02-02) - ✅ **优化数据一致性**: - 调整剧本创建流程:先创建子项目,再创建剧本(消除 UPDATE 操作) - 文件上传在事务外完成,避免长事务 - 数据库操作使用事务保证原子性 - 添加错误回滚逻辑(失败时清理已上传的文件) - 更新时序图,反映优化后的流程 - 更新代码示例和业务规则说明 - ✅ **修复技术栈合规性**: - 修复日志格式化(使用 %-formatting) - 修复 API 响应格式(统一添加 code 字段) - 添加测试指南和数据库迁移说明 ### v1.2 (2026-01-31) - ✅ **新增时序图**: - 添加"上传剧本并创建子项目"的完整时序图 - 添加"点击父项目自动重定向"的交互时序图 - 添加"删除子项目级联删除"的时序图 - 使用 Mermaid 格式,清晰展示各服务间的交互流程 ### v1.1 (2026-01-31) - ✅ **优化前端交互逻辑**: - 移除"子项目详情页"的独立页面布局说明 - 明确子项目界面与父项目完全一致 - 新增"点击父项目自动进入第一个子项目"的逻辑 - 新增 `redirectToFirstSubproject()` 方法实现 - 更新面包屑导航构建逻辑 - 说明页面根据 `is_subproject` 字段区分显示 ### v1.0 (2026-01-31) - 初始版本 --- **文档版本**:v1.3 **最后更新**:2026-02-03