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
目录
需求背景
产品需求
根据 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)
业务价值
- 隔离管理:每个剧本有独立的工作空间(子项目)
- 权限继承:子项目自动继承父项目权限
- 资源归属:分镜、素材等资源自然归属子项目
- 数据一致性:剧本与子项目一对一强绑定
当前问题
实现状态检查
代码搜索结果:
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 次数据库写入
- 评估:可接受(~20-50ms)
-
数据一致性
- 风险:父项目被删除后子项目的处理
- 缓解:已有级联删除逻辑
回滚方案
如果实施后发现问题,可以:
- 临时禁用:设置
auto_create_subproject=False - 数据清理:删除自动创建的子项目(如果没有其他资源)
- 代码回滚:恢复到之前的版本
验收标准
功能验收
- 上传剧本文件后,自动创建子项目
- 子项目名称与剧本同名
- 子项目继承父项目的类型和文件夹
- 剧本的
project_id指向子项目 - API 响应包含
subproject字段 - 可通过
auto_create_subproject=false禁用
性能验收
- 响应时间增加 < 100ms
- 单次请求返回完整信息(剧本 + 子项目)
兼容性验收
- 旧剧本(指向父项目)仍可正常访问
- API 响应格式向后兼容
相关文档
总结
需求核心
上传剧本时自动创建子项目,实现:
- 剧本与子项目一对一绑定
- 子项目作为剧本的独立工作空间
- 分镜等资源自然归属子项目
- 单次请求返回完整信息(剧本 + 子项目)
实现优先级
P0(必须):
- ✅ 自动创建子项目
- ✅ 剧本
project_id指向子项目 - ✅ API 返回
subproject字段
P1(重要):
- ✅
auto_create_subproject参数(支持禁用) - ✅ 事务安全保证
P2(可选):
- 旧数据迁移脚本
- 批量创建子项目工具
实施状态
✅ 已完成实施 (2026-02-06)
实施总结
已实现功能
✅ Schema 层:
- 新增
SubprojectInfoSchema - 修改
FileUploadResponse添加projectId和subproject字段
✅ Service 层:
- 新增
ScreenplayService.create_screenplay_with_subproject()方法 - 修改
ProjectService.create_subproject()支持screenplay_id可选
✅ API 层:
- 修改
upload_and_parse_screenplay接口添加auto_create_subproject参数(默认 true) - 响应包含完整的子项目信息
技术实现亮点
-
增量实现,零破坏
- 保留现有
create_screenplay_from_file()方法 - 新增
create_screenplay_with_subproject()方法 - API 层通过参数选择不同的实现路径
- 保留现有
-
完整的异常处理
- Service 层包含 try-except-rollback 模式
- 所有数据库操作在同一事务中
- 失败时自动回滚,确保数据一致性
-
详细的日志记录
- 使用 %-formatting 符合项目规范
- 记录关键步骤:子项目创建、剧本创建、事务提交
- 错误日志包含 exc_info=True
-
符合技术栈规范
- 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正确指向子项目或父项目 - ✅ 数据库中子项目和剧本正确关联