f5f54b98b6
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 8s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1655h53m46s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1655h53m48s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1655h53m50s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1655h53m48s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1655h53m50s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1656h25m18s
## 问题根因
generate_video Celery 任务完全没有状态更新逻辑:
- 任务开始执行时,不更新 GenerationTask 状态为 running
- 任务成功时,不更新状态为 completed
- 任务失败时,不更新状态为 failed
- GenerationTask 领域模型缺少 mark_processing/mark_completed/mark_failed 方法
结果:任务状态永远停留在 pending,前端查询时一直显示"生成中"或超时显示失败,即使视频实际上已经生成成功了。
## 修复方案
### 1. GenerationTask 领域模型增加完整状态机
- 新增 TERMINAL_STATUSES 终态集合
- 新增 _VALID_TRANSITIONS 合法状态转换表
- 新增 transition_to() 通用状态转换方法(含合法性校验)
- 新增 mark_processing() — pending → running,设置 started_at
- 新增 mark_completed(result_count) — running → completed,设置 completed_at/progress/result_count
- 新增 mark_failed(error_message) — pending/running → failed,设置 error_message/completed_at
- 新增 mark_cancelled() — pending/running → cancelled
- 新增 mark_pending_from_failed() — failed → pending(用于重试)
- 新增 is_terminal/is_completed/is_failed/is_running 属性
状态机:
pending → running → completed
↘ failed → pending (重试)
↘ cancelled
### 2. generate_video Celery 任务增加状态更新
- 新增 _update_task_status() 辅助函数:独立 session + 异常隔离
- 任务开始时:调用 mark_processing() 更新为 running
- 任务成功时:调用 mark_completed() 更新为 completed,传入视频数量
- 任务失败时:在 except 块中调用 mark_failed() 记录错误信息
- _create_video_record_and_dedup 返回值改为 int,方便统计成功数量
状态更新使用独立数据库 session,且所有状态更新操作都包裹在 try/except 中,确保不会因为状态更新失败导致整个任务异常。
### 3. 新增状态机单元测试
新增 tests/unit/test_generation_task_status.py,覆盖 42 个测试用例:
- 初始状态验证
- mark_processing 正常/异常路径
- mark_completed 正常/异常路径
- mark_failed 正常/异常路径
- mark_cancelled 正常/异常路径
- mark_pending_from_failed 重试路径
- transition_to 通用方法
- 完整流转路径(成功、失败、重试、取消)
## 影响范围
- packages/domain/generation_task.py — 领域模型状态机方法
- apps/worker/worker_app/tasks/generation.py — Celery 任务状态更新
- tests/unit/test_generation_task_status.py — 新增单元测试
## 验证方式
- 42 个状态机单元测试全部通过
- 现有 8 个生成相关测试全部通过
- 现有 15 个生成 API 集成测试全部通过
- 现有 6 个编辑计划 worker 失败测试全部通过
- 现有 25 个生成视频管理测试全部通过
248 lines
8.0 KiB
Python
248 lines
8.0 KiB
Python
"""GenerationTask 领域模型 — 视频生成任务.
|
||
|
||
状态机:
|
||
pending → running → completed
|
||
↘ failed → pending (重试)
|
||
↘ cancelled
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
|
||
if sys.version_info >= (3, 11):
|
||
from enum import StrEnum
|
||
else:
|
||
from enum import Enum
|
||
|
||
class StrEnum(str, Enum):
|
||
pass
|
||
|
||
|
||
from uuid import uuid4
|
||
|
||
|
||
class GenerationTaskStatus(StrEnum):
|
||
"""生成任务状态枚举。"""
|
||
|
||
PENDING = "pending"
|
||
"""待处理(任务已创建,等待执行)"""
|
||
|
||
RUNNING = "running"
|
||
"""运行中(正在生成视频)"""
|
||
|
||
COMPLETED = "completed"
|
||
"""已完成(视频生成成功)"""
|
||
|
||
FAILED = "failed"
|
||
"""失败(生成失败)"""
|
||
|
||
CANCELLED = "cancelled"
|
||
"""已取消(用户取消或系统取消)"""
|
||
|
||
|
||
# 终态集合
|
||
TERMINAL_STATUSES = frozenset(
|
||
{GenerationTaskStatus.COMPLETED, GenerationTaskStatus.FAILED, GenerationTaskStatus.CANCELLED}
|
||
)
|
||
|
||
# 合法状态转换
|
||
_VALID_TRANSITIONS: dict[GenerationTaskStatus, set[GenerationTaskStatus]] = {
|
||
GenerationTaskStatus.PENDING: {
|
||
GenerationTaskStatus.RUNNING,
|
||
GenerationTaskStatus.FAILED,
|
||
GenerationTaskStatus.CANCELLED,
|
||
},
|
||
GenerationTaskStatus.RUNNING: {
|
||
GenerationTaskStatus.COMPLETED,
|
||
GenerationTaskStatus.FAILED,
|
||
GenerationTaskStatus.CANCELLED,
|
||
},
|
||
GenerationTaskStatus.FAILED: {GenerationTaskStatus.PENDING}, # 重试回到 pending
|
||
}
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class GenerationTask:
|
||
id: str
|
||
project_id: str
|
||
asset_library_id: str
|
||
strategy_id: str = ""
|
||
voice_library_id: str = ""
|
||
template_id: str = ""
|
||
asset_ids: list[str] = field(default_factory=list)
|
||
title_ids: list[str] = field(default_factory=list)
|
||
voice_ids: list[str] = field(default_factory=list)
|
||
status: GenerationTaskStatus = GenerationTaskStatus.PENDING
|
||
progress: float = 0.0
|
||
result_count: int = 0
|
||
error_message: str = ""
|
||
started_at: datetime | None = None
|
||
completed_at: datetime | None = None
|
||
source_edit_plan_id: str = ""
|
||
created_by_user_id: str = ""
|
||
asset_select_mode: str = ""
|
||
batch_id: str = ""
|
||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||
|
||
@classmethod
|
||
def create(
|
||
cls,
|
||
project_id: str,
|
||
asset_library_id: str,
|
||
*,
|
||
strategy_id: str = "",
|
||
voice_library_id: str = "",
|
||
template_id: str = "",
|
||
asset_ids: list[str] | None = None,
|
||
title_ids: list[str] | None = None,
|
||
voice_ids: list[str] | None = None,
|
||
created_by_user_id: str = "",
|
||
source_edit_plan_id: str = "",
|
||
asset_select_mode: str = "",
|
||
batch_id: str = "",
|
||
) -> "GenerationTask":
|
||
if not project_id.strip() and not template_id.strip():
|
||
raise ValueError("project_id 或 template_id 至少需要提供一个")
|
||
if not asset_library_id.strip() and not (asset_ids or title_ids or voice_ids):
|
||
raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个")
|
||
return cls(
|
||
id=uuid4().hex,
|
||
project_id=project_id.strip(),
|
||
asset_library_id=asset_library_id.strip(),
|
||
strategy_id=strategy_id.strip(),
|
||
voice_library_id=voice_library_id.strip(),
|
||
template_id=template_id.strip(),
|
||
asset_ids=list(asset_ids) if asset_ids else [],
|
||
title_ids=list(title_ids) if title_ids else [],
|
||
voice_ids=list(voice_ids) if voice_ids else [],
|
||
created_by_user_id=created_by_user_id.strip(),
|
||
source_edit_plan_id=source_edit_plan_id.strip(),
|
||
asset_select_mode=asset_select_mode,
|
||
batch_id=batch_id,
|
||
)
|
||
|
||
# ── 状态查询 ────────────────────────────────────────────────────────────
|
||
|
||
@property
|
||
def is_terminal(self) -> bool:
|
||
"""是否处于终态(completed / failed / cancelled)。"""
|
||
return self.status in TERMINAL_STATUSES
|
||
|
||
@property
|
||
def is_completed(self) -> bool:
|
||
"""是否已完成。"""
|
||
return self.status == GenerationTaskStatus.COMPLETED
|
||
|
||
@property
|
||
def is_failed(self) -> bool:
|
||
"""是否失败。"""
|
||
return self.status == GenerationTaskStatus.FAILED
|
||
|
||
@property
|
||
def is_running(self) -> bool:
|
||
"""是否运行中。"""
|
||
return self.status == GenerationTaskStatus.RUNNING
|
||
|
||
# ── 状态转换 ────────────────────────────────────────────────────────────
|
||
|
||
def transition_to(self, new_status: GenerationTaskStatus | str) -> None:
|
||
"""执行状态转换。
|
||
|
||
Args:
|
||
new_status: 目标状态
|
||
|
||
Raises:
|
||
ValueError: 非法状态转换
|
||
"""
|
||
if isinstance(new_status, str):
|
||
try:
|
||
new_status = GenerationTaskStatus(new_status)
|
||
except ValueError:
|
||
raise ValueError(f"无效状态: {new_status}")
|
||
|
||
allowed = _VALID_TRANSITIONS.get(self.status, set())
|
||
if new_status not in allowed:
|
||
raise ValueError(
|
||
f"非法状态转换: {self.status.value} → {new_status.value},"
|
||
f"允许: {{{', '.join(sorted(s.value for s in allowed))}}}"
|
||
)
|
||
|
||
self.status = new_status
|
||
|
||
def mark_processing(self) -> None:
|
||
"""标记为处理中(pending → running)。
|
||
|
||
设置 started_at,清除 error_message。
|
||
|
||
Raises:
|
||
ValueError: 当前状态不允许转换到 running
|
||
"""
|
||
self.transition_to(GenerationTaskStatus.RUNNING)
|
||
self.started_at = datetime.now(timezone.utc)
|
||
self.error_message = ""
|
||
|
||
def mark_completed(self, result_count: int = 1) -> None:
|
||
"""标记为已完成(running → completed)。
|
||
|
||
设置 completed_at、progress=100.0、result_count,清除 error_message。
|
||
|
||
Args:
|
||
result_count: 生成的视频数量,默认为 1
|
||
|
||
Raises:
|
||
ValueError: 当前状态不允许转换到 completed
|
||
"""
|
||
self.transition_to(GenerationTaskStatus.COMPLETED)
|
||
self.completed_at = datetime.now(timezone.utc)
|
||
self.progress = 100.0
|
||
self.result_count = result_count
|
||
self.error_message = ""
|
||
|
||
def mark_failed(self, error_message: str) -> None:
|
||
"""标记为失败(pending / running → failed)。
|
||
|
||
设置 error_message、completed_at。
|
||
|
||
Args:
|
||
error_message: 错误信息
|
||
|
||
Raises:
|
||
ValueError: 当前状态不允许转换到 failed
|
||
"""
|
||
self.transition_to(GenerationTaskStatus.FAILED)
|
||
self.error_message = error_message
|
||
self.completed_at = datetime.now(timezone.utc)
|
||
|
||
def mark_cancelled(self) -> None:
|
||
"""标记为已取消(pending / running → cancelled)。
|
||
|
||
设置 completed_at。
|
||
|
||
Raises:
|
||
ValueError: 当前状态不允许转换到 cancelled
|
||
"""
|
||
self.transition_to(GenerationTaskStatus.CANCELLED)
|
||
self.completed_at = datetime.now(timezone.utc)
|
||
|
||
def mark_pending_from_failed(self) -> None:
|
||
"""从失败状态重置为待处理(用于重试)。
|
||
|
||
清除 error_message、started_at、completed_at、progress。
|
||
|
||
Raises:
|
||
ValueError: 当前状态不是 failed
|
||
"""
|
||
if self.status != GenerationTaskStatus.FAILED:
|
||
raise ValueError(
|
||
f"只有 failed 状态的任务可以重置为 pending,当前状态: {self.status.value}"
|
||
)
|
||
self.transition_to(GenerationTaskStatus.PENDING)
|
||
self.error_message = ""
|
||
self.started_at = None
|
||
self.completed_at = None
|
||
self.progress = 0.0
|
||
self.result_count = 0
|