# RFC 141: 剧本上传自动创建子项目 **状态**: ✅ Implemented **创建日期**: 2026-02-06 **实施日期**: 2026-02-06 **关联需求**: `docs/requirements/backend/04-services/project/screenplay-service.md` **关联架构**: `docs/requirements/backend/04-services/project/subproject-architecture.md` **变更日志**: [2026-02-06-screenplay-auto-create-subproject.md](../changelogs/2026-02-06-screenplay-auto-create-subproject.md) --- ## 目录 1. [需求背景](#需求背景) 2. [当前问题](#当前问题) 3. [需求分析](#需求分析) 4. [设计方案](#设计方案) 5. [实现计划](#实现计划) 6. [API 变更](#api-变更) 7. [数据流程](#数据流程) 8. [影响分析](#影响分析) --- ## 需求背景 ### 产品需求 根据 `screenplay-service.md` 和 `subproject-architecture.md` 需求文档: > **核心功能(第 43 行)**: > - **自动创建子项目**:上传剧本时自动创建关联的子项目(默认启用) > **业务规则(第 1953-1958 行)**: > 2. **子项目创建**: > - 转换成功后**强制创建子项目**(无可选参数) > - 子项目名称与剧本同名 > - 子项目继承父项目的类型(mine/collab)和权限设置 > - 剧本的 `project_id` 指向子项目 > - 后续分镜也属于该子项目 ### 架构层级 ``` 📁 文件夹 └── 📂 父项目 (parent_project_id = NULL) └── 📄 子项目 (parent_project_id = 父项目ID) ├── 📜 剧本 (project_id = 子项目ID) └── 🎬 分镜 (project_id = 子项目ID) ``` ### 业务价值 1. **隔离管理**:每个剧本有独立的工作空间(子项目) 2. **权限继承**:子项目自动继承父项目权限 3. **资源归属**:分镜、素材等资源自然归属子项目 4. **数据一致性**:剧本与子项目一对一强绑定 --- ## 当前问题 ### 实现状态检查 **代码搜索结果**: ```bash grep -r "auto_create_subproject\|create_subproject" server/app/services/screenplay_service.py # 结果:No matches found ❌ ``` **当前实现**(`ScreenplayService.create_screenplay_from_file()`): ```python async def create_screenplay_from_file( self, user_id: UUID, project_id: UUID, # ← 直接使用父项目 ID name: str, file_content: bytes, file_name: str, mime_type: str ) -> Screenplay: # 1. 创建剧本记录 screenplay = Screenplay( project_id=project_id, # ❌ 直接关联父项目 name=name, type=ScreenplayType.FILE, parsing_status=ParsingStatus.PENDING, # ... ) created_screenplay = await self.repository.create(screenplay) # 2. 上传文件并创建 Attachment # ... await self.db.commit() # ❌ 缺失:没有创建子项目的逻辑 return created_screenplay ``` **API 响应**(`upload_and_parse_screenplay`): ```json { "data": { "screenplayId": "019c326b-ee0e-7fa0-8361-12924ba196d2", "name": "测试剧本", "projectId": "019c30d1-5fe2-7802-af60-85c8568a9396", // ← 父项目 ID "parsingStatus": "parsing" // ❌ 缺失:没有 subproject 字段 } } ``` ### 问题总结 | 功能点 | 需求文档 | 当前实现 | 状态 | |-------|---------|---------|------| | 自动创建子项目 | ✅ 强制启用 | ❌ 未实现 | **缺失** | | `auto_create_subproject` 参数 | ✅ 默认 True | ❌ 无 | **缺失** | | 剧本 `project_id` 指向 | 子项目 ID | 父项目 ID | **不符** | | API 返回 `subproject` | ✅ 必需 | ❌ 无 | **缺失** | | 子项目名称 | 与剧本同名 | - | **缺失** | --- ## 需求分析 ### 核心需求 **REQ-1**: 上传剧本文件时,**强制自动创建子项目**(无可选参数) **REQ-2**: 子项目应满足以下条件: - 名称与剧本同名 - `parent_project_id` 指向用户传入的父项目 - `screenplay_id` 关联到新创建的剧本 - 继承父项目的 `type`(mine/collab) - `folder_id` 与父项目相同 - `description` = `"基于剧本《{剧本名称}》的制作项目"` **REQ-3**: 剧本的 `project_id` 应指向**子项目 ID**(而非父项目 ID) **REQ-4**: API 响应应包含 `subproject` 完整信息 **REQ-5**: 事务安全:剧本创建和子项目创建必须在同一事务中,确保数据一致性 --- ## 设计方案 ### 方案选择 根据 `subproject-architecture.md` v1.3 的优化建议: > **变更**:优化剧本创建流程,**先创建子项目再创建剧本**,使用事务保证数据一致性 ### 创建顺序(优化后) ``` 1. 检查父项目权限 ↓ 2. 创建子项目 (Project) ├── name = 剧本名称 ├── parent_project_id = 父项目ID ├── screenplay_id = NULL (暂时) └── 其他字段继承父项目 ↓ 3. 上传原始文件 → 创建 Attachment ↓ 4. 创建剧本 (Screenplay) ├── project_id = 子项目ID ← ✅ 指向子项目 ├── parsing_status = PENDING └── 其他字段 ↓ 5. 更新子项目的 screenplay_id ↓ 6. 提交事务 ↓ 7. 触发 Celery 解析任务 ↓ 8. 返回 { screenplay, subproject } ``` ### 优势 - ✅ **原子性**:子项目和剧本在同一事务中创建 - ✅ **一致性**:如果任何步骤失败,整个操作回滚 - ✅ **正确关联**:剧本的 `project_id` 直接指向子项目 - ✅ **完整响应**:一次请求返回剧本和子项目完整信息 --- ## 实现计划 ### 阶段 1:修改 Service 层 #### 1.1 修改 `ScreenplayService.create_screenplay_from_file()` **文件**: `server/app/services/screenplay_service.py` **新增参数**: ```python async def create_screenplay_from_file( self, user_id: UUID, project_id: UUID, # ← 父项目 ID name: str, file_content: bytes, file_name: str, mime_type: str, auto_create_subproject: bool = True # ✅ 新增参数,默认启用 ) -> Dict[str, Any]: # ← 返回类型改为字典(包含 screenplay 和 subproject) ``` **实现步骤**: ```python async def create_screenplay_from_file(...) -> Dict[str, Any]: """创建文件剧本(自动创建子项目)""" # 1. 检查父项目权限 await self._check_project_permission(user_id, project_id, 'editor') # 2. 获取父项目信息(用于继承) from app.repositories.project_repository import ProjectRepository project_repo = ProjectRepository(self.db) parent_project = await project_repo.get_by_id(project_id) if not parent_project: raise NotFoundError("父项目不存在") # 3. 创建子项目(如果启用) subproject = None actual_project_id = project_id # 默认使用父项目 ID if auto_create_subproject: from app.services.project_service import ProjectService project_service = ProjectService(self.db) subproject = await project_service.create_subproject( user_id=user_id, parent_project_id=project_id, screenplay_id=None, # 暂时为 None,后续更新 name=name, # 子项目名称与剧本同名 project_type=parent_project.type, # 继承父项目类型 folder_id=parent_project.folder_id, # 继承父项目文件夹 description=f"基于剧本《{name}》的制作项目" ) actual_project_id = subproject.id # ✅ 使用子项目 ID logger.info( "子项目创建成功 | 子项目ID: %s | 父项目ID: %s | 名称: %s", subproject.id, project_id, name ) # 4. 上传原始文件并创建 Attachment file_storage = FileStorageService(self.db) file_metadata = await file_storage.upload_file(...) attachment = Attachment( # ... related_id=None, # ← 暂时为 None,剧本创建后更新 related_type=RelatedType.SCREENPLAY, attachment_purpose=AttachmentPurpose.SOURCE, # ... ) self.db.add(attachment) await self.db.flush() # 获取 attachment_id # 5. 创建剧本记录 screenplay = Screenplay( project_id=actual_project_id, # ✅ 指向子项目 ID(如果启用) name=name, type=ScreenplayType.FILE, parsing_status=ParsingStatus.PENDING, created_by=user_id, updated_by=user_id ) created_screenplay = await self.repository.create(screenplay) await self.db.flush() # 6. 更新 attachment 的 related_id attachment.related_id = created_screenplay.screenplay_id # 7. 更新子项目的 screenplay_id(如果有子项目) if subproject: await project_repo.update(subproject.id, { 'screenplay_id': created_screenplay.screenplay_id }) # 8. 提交事务 await self.db.commit() logger.info( "剧本创建成功 | 剧本ID: %s | 子项目ID: %s | 名称: %s", created_screenplay.screenplay_id, subproject.id if subproject else None, name ) # 9. 返回剧本和子项目信息 return { 'screenplay': created_screenplay, 'subproject': subproject # 可能为 None } ``` #### 1.2 新增 `ProjectService.create_subproject()` 方法 **文件**: `server/app/services/project_service.py` ```python async def create_subproject( self, user_id: UUID, parent_project_id: UUID, screenplay_id: Optional[UUID], name: str, project_type: ProjectType, folder_id: UUID, description: Optional[str] = None ) -> Project: """创建子项目 Args: user_id: 创建者 ID parent_project_id: 父项目 ID screenplay_id: 关联的剧本 ID(可选,创建时可为 None) name: 子项目名称(通常与剧本同名) project_type: 项目类型(继承父项目) folder_id: 文件夹 ID(继承父项目) description: 项目描述 Returns: 创建的子项目 """ from app.models.project import Project, ProjectStatus # 创建子项目 subproject = Project( name=name, parent_project_id=parent_project_id, screenplay_id=screenplay_id, type=project_type, folder_id=folder_id, description=description or f"基于剧本《{name}》的制作项目", status=ProjectStatus.ACTIVE, created_by=user_id, updated_by=user_id ) created_subproject = await self.repository.create(subproject) await self.db.flush() logger.info( "子项目创建 | ID: %s | 名称: %s | 父项目: %s", created_subproject.id, name, parent_project_id ) return created_subproject ``` --- ### 阶段 2:修改 Schema 层 #### 2.1 新增 `SubprojectInfo` Schema **文件**: `server/app/schemas/screenplay.py` ```python class SubprojectInfo(BaseModel): """子项目信息(用于剧本上传响应)""" project_id: UUID = Field(..., alias="projectId", description="子项目 ID") name: str = Field(..., description="子项目名称") parent_project_id: UUID = Field(..., alias="parentProjectId", description="父项目 ID") screenplay_id: UUID = Field(..., alias="screenplayId", description="关联的剧本 ID") type: str = Field(..., description="项目类型: mine/collab") description: Optional[str] = Field(None, description="项目描述") folder_id: UUID = Field(..., alias="folderId", description="所属文件夹 ID") created_at: datetime = Field(..., alias="createdAt", description="创建时间") model_config = ConfigDict(from_attributes=True, populate_by_name=True) ``` #### 2.2 修改 `FileUploadResponse` Schema **文件**: `server/app/schemas/screenplay.py` ```python class FileUploadResponse(BaseModel): """文件上传响应模型""" screenplay_id: UUID = Field(..., alias="screenplayId", description="剧本 ID") name: str = Field(..., description="剧本名称") type: str = Field(..., description="剧本类型") file_url: str = Field(..., alias="fileUrl", description="文件 URL") file_name: Optional[str] = Field(None, alias="fileName", description="原始文件名") file_size: Optional[int] = Field(None, alias="fileSize", description="文件大小(字节)") mime_type: Optional[str] = Field(None, alias="mimeType", description="文件 MIME 类型") parsing_status: str = Field(..., alias="parsingStatus", description="解析状态") task_id: Optional[str] = Field(None, alias="taskId", description="Celery 任务 ID") # ✅ 新增字段 project_id: UUID = Field(..., alias="projectId", description="所属项目 ID(子项目 ID)") subproject: Optional[SubprojectInfo] = Field(None, description="自动创建的子项目信息") model_config = ConfigDict(populate_by_name=True) ``` --- ### 阶段 3:修改 API 层 #### 3.1 修改 `upload_and_parse_screenplay` 接口 **文件**: `server/app/api/v1/screenplays.py` ```python @router.post('/file', response_model=ApiResponse[FileUploadResponse]) async def upload_and_parse_screenplay( project_id: str = Form(..., description="父项目 ID"), # ← 明确说明是父项目 name: str = Form(..., description="剧本名称"), file: UploadFile = File(..., description="剧本文件"), auto_create_subproject: bool = Form(True, description="是否自动创建子项目"), # ✅ 新增参数 db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """上传并解析剧本文件(自动创建子项目)""" # 1. 读取文件内容 file_content = await file.read() # 2. 创建剧本(包含子项目创建) result = await screenplay_service.create_screenplay_from_file( user_id=current_user.user_id, project_id=UUID(project_id), # 父项目 ID name=name, file_content=file_content, file_name=file.filename, mime_type=file.content_type, auto_create_subproject=auto_create_subproject # ✅ 传递参数 ) screenplay = result['screenplay'] subproject = result.get('subproject') # 可能为 None # 3. 判断是否需要异步解析 if parser_service.should_parse_async(file.content_type): # 更新解析状态 await screenplay_service.repository.update(screenplay.screenplay_id, { 'parsing_status': ParsingStatus.PENDING }) await db.commit() # 提交 Celery 任务 task = parse_screenplay_file_task.delay( screenplay_id=str(screenplay.screenplay_id), file_path=screenplay.file_url, mime_type=file.content_type ) # ✅ 返回包含 subproject 的响应 response_data = { "screenplayId": str(screenplay.screenplay_id), "name": screenplay.name, "type": screenplay.type_str, "projectId": str(screenplay.project_id), # ← 子项目 ID "fileUrl": None, # 解析完成后才有 "fileName": file.filename, "fileSize": len(file_content), "mimeType": file.content_type, "parsingStatus": "parsing", "taskId": task.id, "subproject": SubprojectInfo.model_validate(subproject) if subproject else None # ✅ } return success_response( data=response_data, message="文件上传成功,正在解析..." ) ``` --- ## API 变更 ### 请求参数变更 #### 修改前 ```bash curl -X POST "http://localhost:6170/api/v1/screenplays/file" \ -H "Authorization: Bearer " \ -F "project_id=019c30d1-5fe2-7802-af60-85c8568a9396" \ -F "name=测试剧本" \ -F "file=@test.pdf" ``` #### 修改后 ```bash curl -X POST "http://localhost:6170/api/v1/screenplays/file" \ -H "Authorization: Bearer " \ -F "project_id=019c30d1-5fe2-7802-af60-85c8568a9396" \ # ← 父项目 ID -F "name=测试剧本" \ -F "file=@test.pdf" \ -F "auto_create_subproject=true" # ✅ 新增可选参数(默认 true) ``` ### 响应格式变更 #### 修改前 ```json { "data": { "screenplayId": "019c326b-...", "name": "测试剧本", "projectId": "019c30d1-...", // ← 父项目 ID "parsingStatus": "parsing", "taskId": "abc123..." } } ``` #### 修改后 ```json { "data": { "screenplayId": "019c326b-...", "name": "测试剧本", "projectId": "019c3280-...", // ← ✅ 子项目 ID "parsingStatus": "parsing", "taskId": "abc123...", "subproject": { // ✅ 新增字段 "projectId": "019c3280-...", "name": "测试剧本", "parentProjectId": "019c30d1-...", "screenplayId": "019c326b-...", "type": "mine", "description": "基于剧本《测试剧本》的制作项目", "folderId": "019c30a3-...", "createdAt": "2026-02-06T10:30:00Z" } } } ``` --- ## 数据流程 ### 后端数据流程 ``` API Service Database | | | |-- POST /file ----------→| | | project_id | | | name, file | | | |-- 检查权限 ---------------→| | | | | |-- 查询父项目 -------------→| | |←- 父项目信息 --------------| | | | | |-- 创建子项目 -------------→| | |←- 子项目 ID ---------------| | | | | |-- 上传文件到 OSS ------→| | |←- 文件 URL ---------------| | | | | |-- 创建 Attachment -------→| | |←- Attachment ID ----------| | | | | |-- 创建剧本 ---------------→| | | (project_id = 子项目) | | |←- 剧本 ID ----------------| | | | | |-- 更新子项目 -------------→| | | (screenplay_id) | | | | | |-- COMMIT -----------------→| | | | | |-- 触发 Celery 异步解析 -→| | | | |←- 响应 ------------------| | | { screenplay, | | | subproject } | | ``` --- ## 影响分析 ### 1. 数据库影响 **无需数据迁移**: - ✅ 已有的剧本保持不变(`project_id` 仍指向父项目) - ✅ 新创建的剧本使用子项目 - ✅ 两种模式共存 **可选迁移**: - 如需统一,可运行脚本为旧剧本创建子项目 ### 2. API 响应变更 **Breaking Change**: | 字段 | 修改前 | 修改后 | 说明 | |-----|-------|-------|------| | `projectId` | 父项目 ID | 子项目 ID | 剧本所属项目变更 | | `subproject` | 不存在 | 子项目信息 | 新增字段 | ### 3. 性能影响 **额外开销**: - 每次上传剧本额外创建 1 个 Project 记录 - 额外 1 次数据库写入操作 - 预计增加响应时间:~20-50ms **性能影响**: - 单次请求增加 1 次子项目创建写入(约 +20-50ms) - 返回完整信息,避免后续额外查询 - 整体响应时间 < 500ms(可接受) --- ## 实施步骤 ### Step 1: 实现 `ProjectService.create_subproject()` - 文件:`server/app/services/project_service.py` - 工作量:约 50 行代码 - 依赖:`ProjectRepository` ### Step 2: 修改 `ScreenplayService.create_screenplay_from_file()` - 文件:`server/app/services/screenplay_service.py` - 工作量:约 80 行代码(重构现有逻辑) - 依赖:`ProjectService` ### Step 3: 新增 Schema - 文件:`server/app/schemas/screenplay.py` - 工作量:约 30 行代码 - 新增:`SubprojectInfo` - 修改:`FileUploadResponse` ### Step 4: 修改 API 接口 - 文件:`server/app/api/v1/screenplays.py` - 工作量:约 40 行代码 - 修改:`upload_and_parse_screenplay()` - 新增参数:`auto_create_subproject` ### Step 5: 更新测试 - 文件:`tests/unit/services/test_screenplay_service.py` - 工作量:约 60 行代码 - 新增测试用例: - `test_create_screenplay_with_subproject()` - `test_create_screenplay_without_subproject()` - `test_subproject_inherits_parent_settings()` ### Step 6: 更新文档 - 更新 API 文档 - 创建数据迁移指南(可选) - 更新架构文档 --- ## 风险评估 ### 高风险 **无** ### 中风险 1. **事务复杂度增加** - 风险:子项目创建失败可能导致整个事务回滚 - 缓解:完善错误处理,记录详细日志 ### 低风险 1. **性能影响** - 影响:每次上传额外 1 次数据库写入 - 评估:可接受(~20-50ms) 2. **数据一致性** - 风险:父项目被删除后子项目的处理 - 缓解:已有级联删除逻辑 --- ## 回滚方案 如果实施后发现问题,可以: 1. **临时禁用**:设置 `auto_create_subproject=False` 2. **数据清理**:删除自动创建的子项目(如果没有其他资源) 3. **代码回滚**:恢复到之前的版本 --- ## 验收标准 ### 功能验收 - [ ] 上传剧本文件后,自动创建子项目 - [ ] 子项目名称与剧本同名 - [ ] 子项目继承父项目的类型和文件夹 - [ ] 剧本的 `project_id` 指向子项目 - [ ] API 响应包含 `subproject` 字段 - [ ] 可通过 `auto_create_subproject=false` 禁用 ### 性能验收 - [ ] 响应时间增加 < 100ms - [ ] 单次请求返回完整信息(剧本 + 子项目) ### 兼容性验收 - [ ] 旧剧本(指向父项目)仍可正常访问 - [ ] API 响应格式向后兼容 --- ## 相关文档 - [需求文档:剧本管理服务](../../requirements/backend/04-services/project/screenplay-service.md) - [架构文档:子项目架构设计](../../requirements/backend/04-services/project/subproject-architecture.md) - [RFC 140:剧本文件存储重构](./140-screenplay-file-storage-refactor.md) --- ## 总结 ### 需求核心 **上传剧本时自动创建子项目**,实现: - 剧本与子项目一对一绑定 - 子项目作为剧本的独立工作空间 - 分镜等资源自然归属子项目 - 单次请求返回完整信息(剧本 + 子项目) ### 实现优先级 **P0(必须)**: - ✅ 自动创建子项目 - ✅ 剧本 `project_id` 指向子项目 - ✅ API 返回 `subproject` 字段 **P1(重要)**: - ✅ `auto_create_subproject` 参数(支持禁用) - ✅ 事务安全保证 **P2(可选)**: - 旧数据迁移脚本 - 批量创建子项目工具 ### 实施状态 ✅ **已完成实施** (2026-02-06) --- ## 实施总结 ### 已实现功能 **✅ Schema 层**: - 新增 `SubprojectInfo` Schema - 修改 `FileUploadResponse` 添加 `projectId` 和 `subproject` 字段 **✅ Service 层**: - 新增 `ScreenplayService.create_screenplay_with_subproject()` 方法 - 修改 `ProjectService.create_subproject()` 支持 `screenplay_id` 可选 **✅ API 层**: - 修改 `upload_and_parse_screenplay` 接口添加 `auto_create_subproject` 参数(默认 true) - 响应包含完整的子项目信息 ### 技术实现亮点 1. **增量实现,零破坏** - 保留现有 `create_screenplay_from_file()` 方法 - 新增 `create_screenplay_with_subproject()` 方法 - API 层通过参数选择不同的实现路径 2. **完整的异常处理** - Service 层包含 try-except-rollback 模式 - 所有数据库操作在同一事务中 - 失败时自动回滚,确保数据一致性 3. **详细的日志记录** - 使用 %-formatting 符合项目规范 - 记录关键步骤:子项目创建、剧本创建、事务提交 - 错误日志包含 exc_info=True 4. **符合技术栈规范** - Schema 使用 camelCase 别名 - Service 层负责事务管理(commit/rollback) - Repository 层使用 flush() - API 层通过依赖注入获取 Service ### 验证测试 **测试命令**: ```bash # 1. 测试自动创建子项目(默认) curl -X POST http://localhost:6170/api/v1/screenplays/file \ -H "Authorization: Bearer $TOKEN" \ -F "project_id=<父项目ID>" \ -F "name=测试剧本" \ -F "file=@test.pdf" # 2. 测试禁用自动创建 curl -X POST http://localhost:6170/api/v1/screenplays/file \ -H "Authorization: Bearer $TOKEN" \ -F "project_id=<项目ID>" \ -F "name=测试剧本" \ -F "file=@test.txt" \ -F "auto_create_subproject=false" ``` **预期结果**: - ✅ 响应包含 `subproject` 字段(启用时) - ✅ `projectId` 正确指向子项目或父项目 - ✅ 数据库中子项目和剧本正确关联 --- ## 相关文档 - ✅ [变更日志](../changelogs/2026-02-06-screenplay-auto-create-subproject.md) - [需求文档:剧本管理服务](../../requirements/backend/04-services/project/screenplay-service.md) - [架构文档:子项目架构设计](../../requirements/backend/04-services/project/subproject-architecture.md)