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.
 

25 KiB

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


目录

  1. 需求背景
  2. 当前问题
  3. 需求分析
  4. 设计方案
  5. 实现计划
  6. API 变更
  7. 数据流程
  8. 影响分析

需求背景

产品需求

根据 screenplay-service.mdsubproject-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. 数据一致性:剧本与子项目一对一强绑定

当前问题

实现状态检查

代码搜索结果

grep -r "auto_create_subproject\|create_subproject" server/app/services/screenplay_service.py
# 结果:No matches found  ❌

当前实现ScreenplayService.create_screenplay_from_file()):

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):

{
  "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

新增参数

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)

实现步骤

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

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

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

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

@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 变更

请求参数变更

修改前

curl -X POST "http://localhost:6170/api/v1/screenplays/file" \
  -H "Authorization: Bearer <token>" \
  -F "project_id=019c30d1-5fe2-7802-af60-85c8568a9396" \
  -F "name=测试剧本" \
  -F "file=@test.pdf"

修改后

curl -X POST "http://localhost:6170/api/v1/screenplays/file" \
  -H "Authorization: Bearer <token>" \
  -F "project_id=019c30d1-5fe2-7802-af60-85c8568a9396" \  # ← 父项目 ID
  -F "name=测试剧本" \
  -F "file=@test.pdf" \
  -F "auto_create_subproject=true"  # ✅ 新增可选参数(默认 true)

响应格式变更

修改前

{
  "data": {
    "screenplayId": "019c326b-...",
    "name": "测试剧本",
    "projectId": "019c30d1-...",  //  父项目 ID
    "parsingStatus": "parsing",
    "taskId": "abc123..."
  }
}

修改后

{
  "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 响应格式向后兼容

相关文档


总结

需求核心

上传剧本时自动创建子项目,实现:

  • 剧本与子项目一对一绑定
  • 子项目作为剧本的独立工作空间
  • 分镜等资源自然归属子项目
  • 单次请求返回完整信息(剧本 + 子项目)

实现优先级

P0(必须)

  • 自动创建子项目
  • 剧本 project_id 指向子项目
  • API 返回 subproject 字段

P1(重要)

  • auto_create_subproject 参数(支持禁用)
  • 事务安全保证

P2(可选)

  • 旧数据迁移脚本
  • 批量创建子项目工具

实施状态

已完成实施 (2026-02-06)


实施总结

已实现功能

Schema 层

  • 新增 SubprojectInfo Schema
  • 修改 FileUploadResponse 添加 projectIdsubproject 字段

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

验证测试

测试命令

# 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 正确指向子项目或父项目
  • 数据库中子项目和剧本正确关联

相关文档