diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 3b178de09..5d3c90c90 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -141,6 +141,19 @@ class EditPlanService: logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name) return created + def _auto_resume_editing(self, plan_id: str) -> None: + """如果计划处于 completed/failed 状态,自动切回 editing(编辑操作前置)""" + plan = self._plan_repo.get(plan_id) + if plan is None: + return + if plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED): + try: + plan.resume_editing() + self._plan_repo.update(plan) + logger.info("自动重新编辑: plan_id=%s", plan_id) + except ValueError: + pass + def update_plan( self, plan_id: str, @@ -156,6 +169,10 @@ class EditPlanService: """ existing = self.get_plan_or_raise(plan_id) + # 自动从 completed/failed 切回 editing + self._auto_resume_editing(plan_id) + existing = self.get_plan_or_raise(plan_id) + updated = EditPlan( id=existing.id, template_id=existing.template_id, @@ -212,8 +229,24 @@ class EditPlanService: return plan # 根据目标状态调用对应的状态机方法 + # EDITING 支持从 draft / completed / failed 进入 + if target_status == EditPlanStatus.EDITING: + if plan.status == EditPlanStatus.DRAFT: + plan.start_editing() + elif plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED): + plan.resume_editing() + else: + raise ValueError(f"无法从 {plan.status} 切换到 {target_status}") + result = self._plan_repo.update(plan) + logger.info( + "状态流转: plan_id=%s %s → %s", + plan_id, + plan.status, + target_status, + ) + return result + transition_map = { - EditPlanStatus.EDITING: plan.start_editing, EditPlanStatus.RENDERING: plan.start_rendering, EditPlanStatus.COMPLETED: plan.mark_completed, EditPlanStatus.FAILED: plan.mark_failed, @@ -292,6 +325,8 @@ class EditPlanService: """ # 确保计划存在 self.get_plan_or_raise(plan_id) + # 自动从 completed/failed 切回 editing + self._auto_resume_editing(plan_id) clip = EditPlanClip.create( plan_id=plan_id, @@ -339,6 +374,9 @@ class EditPlanService: """ existing = self.get_clip_or_raise(clip_id) + # 自动从 completed/failed 切回 editing + self._auto_resume_editing(existing.plan_id) + # 速度边界钳制 if playback_speed is not None: if playback_speed <= 0: @@ -381,6 +419,8 @@ class EditPlanService: ValueError: 片段不存在或 asset_id 为空 """ clip = self.get_clip_or_raise(clip_id) + # 自动从 completed/failed 切回 editing + self._auto_resume_editing(clip.plan_id) clip.assign_asset(asset_id) result = self._clip_repo.update(clip) logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id) @@ -511,6 +551,9 @@ class EditPlanService: 更新后的计划 """ plan = self.get_plan_or_raise(plan_id) + # 自动从 completed/failed 切回 editing + self._auto_resume_editing(plan_id) + plan = self.get_plan_or_raise(plan_id) new_config = {**plan.config, **config_updates} updated = EditPlan( diff --git a/packages/domain/edit_plan.py b/packages/domain/edit_plan.py index 4d8ad875b..7b7467da6 100755 --- a/packages/domain/edit_plan.py +++ b/packages/domain/edit_plan.py @@ -110,6 +110,13 @@ class EditPlan: self.status = EditPlanStatus.FAILED self.updated_at = datetime.now(timezone.utc) + def resume_editing(self) -> None: + """重新进入编辑状态(完成/失败后重新编辑)""" + if self.status not in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED): + raise ValueError(f"只有 completed/failed 状态的计划可以重新编辑,当前状态: {self.status}") + self.status = EditPlanStatus.EDITING + self.updated_at = datetime.now(timezone.utc) + def reset_to_draft(self) -> None: """重置为草稿状态(仅从 failed 状态可重置)""" if self.status != EditPlanStatus.FAILED: diff --git a/tests/unit/test_edit_plan_service.py b/tests/unit/test_edit_plan_service.py index f67335642..aae0e2b5e 100755 --- a/tests/unit/test_edit_plan_service.py +++ b/tests/unit/test_edit_plan_service.py @@ -636,3 +636,94 @@ class TestGenerationWorkflow: p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"}) updated = svc.update_plan_config(p.id, {"key1": "new_val"}) assert updated.config["key1"] == "new_val" + + +# ── 重新编辑 & 再生成 ──────────────────────────────────────────────────────── + + +class TestResumeEditingAndRegenerate: + """完成/失败后重新编辑 → 再生成的状态流转测试""" + + def test_update_plan_from_completed_returns_to_editing(self): + """更新计划配置:completed → 自动切回 editing""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.transition_status(p.id, EditPlanStatus.RENDERING) + svc.transition_status(p.id, EditPlanStatus.COMPLETED) + + updated = svc.update_plan(p.id, name="新名字") + assert updated.status == EditPlanStatus.EDITING + assert updated.name == "新名字" + + def test_update_plan_config_from_completed_returns_to_editing(self): + """update_plan_config: completed → 自动切回 editing""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.transition_status(p.id, EditPlanStatus.RENDERING) + svc.transition_status(p.id, EditPlanStatus.COMPLETED) + + updated = svc.update_plan_config(p.id, {"foo": "bar"}) + assert updated.status == EditPlanStatus.EDITING + + def test_create_clip_from_completed_returns_to_editing(self): + """创建片段:completed → 自动切回 editing""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.transition_status(p.id, EditPlanStatus.RENDERING) + svc.transition_status(p.id, EditPlanStatus.COMPLETED) + + svc.create_clip(p.id, "main", 0) + plan_after = svc.get_plan(p.id) + assert plan_after.status == EditPlanStatus.EDITING + + def test_assign_asset_from_failed_returns_to_editing(self): + """分配素材:failed → 自动切回 editing""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + clip = svc.create_clip(p.id, "main", 0) + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.transition_status(p.id, EditPlanStatus.RENDERING) + svc.transition_status(p.id, EditPlanStatus.FAILED) + + svc.assign_asset(clip.id, "asset-001") + plan_after = svc.get_plan(p.id) + assert plan_after.status == EditPlanStatus.EDITING + + def test_completed_can_regenerate_after_edit(self): + """完成后编辑 → can_generate 返回 True,可再生成""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.create_clip(p.id, "main", 0) + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.transition_status(p.id, EditPlanStatus.RENDERING) + svc.transition_status(p.id, EditPlanStatus.COMPLETED) + + # 完成后不能直接生成 + can, reason = svc.can_generate(p.id) + assert not can + assert "编辑" in reason + + # 编辑后自动切回 editing,可以生成 + svc.update_plan_config(p.id, {"edited": True}) + can, reason = svc.can_generate(p.id) + assert can, f"期望可生成,实际: {reason}" + + def test_transition_completed_to_editing_via_service(self): + """通过 transition_status 从 completed 切到 editing""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.transition_status(p.id, EditPlanStatus.RENDERING) + svc.transition_status(p.id, EditPlanStatus.COMPLETED) + + result = svc.transition_status(p.id, EditPlanStatus.EDITING) + assert result.status == EditPlanStatus.EDITING + + def test_resume_editing_from_draft_raises(self): + """从 draft 直接 resume_editing 应该报错""" + p = EditPlan.create("tpl-001", "测试") + with pytest.raises(ValueError): + p.resume_editing()