43366f290c
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 181h43m15s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 181h43m19s
Deploy / Deploy Staging (push) Failing after 181h59m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 181h59m53s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 182h0m0s
- 新增 Job 领域模型(状态机、JobType/JobStatus 枚举) - 新增 JobRepository 端口 + SQLAlchemy 实现 - 新增 10 个 Use Cases(CreateJob/Submit/Progress/Complete/Fail/Retry/Cancel/Get/List/Statistics) - 新增 JobService 服务层,集成 VideoComposeService - 新增 Pydantic schemas + RESTful API 路由 - 新增 Celery compose_video 任务(含进度追踪) - 新增数据库迁移 018(jobs 表) - 新增 44 个单元测试,全部通过 - 修复状态转换:允许 pending→success(快速完成场景)
290 lines
8.8 KiB
Python
Executable File
290 lines
8.8 KiB
Python
Executable File
"""Job 领域模型 — Phase 8 任务 2.10.
|
||
|
||
统一的异步任务抽象,为视频合成等耗时操作提供异步任务管理能力。
|
||
|
||
状态机:
|
||
pending → running → success
|
||
↘ 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 JobType(StrEnum):
|
||
"""任务类型枚举。"""
|
||
|
||
VIDEO_COMPOSE = "video_compose"
|
||
"""视频合成(VideoComposeService)"""
|
||
|
||
RENDER_EDIT_PLAN = "render_edit_plan"
|
||
"""剪辑计划渲染"""
|
||
|
||
ASSET_INGEST = "asset_ingest"
|
||
"""素材导入"""
|
||
|
||
CLASSIFICATION = "classification"
|
||
"""素材分类"""
|
||
|
||
VOICE_EXTRACTION = "voice_extraction"
|
||
"""语音提取"""
|
||
|
||
GENERATION = "generation"
|
||
"""通用生成"""
|
||
|
||
|
||
class JobStatus(StrEnum):
|
||
"""任务状态枚举。"""
|
||
|
||
PENDING = "pending"
|
||
RUNNING = "running"
|
||
SUCCESS = "success"
|
||
FAILED = "failed"
|
||
CANCELLED = "cancelled"
|
||
|
||
|
||
# 终态集合
|
||
TERMINAL_STATUSES = frozenset({JobStatus.SUCCESS, JobStatus.FAILED, JobStatus.CANCELLED})
|
||
|
||
# 合法状态转换
|
||
_VALID_TRANSITIONS: dict[JobStatus, set[JobStatus]] = {
|
||
JobStatus.PENDING: {JobStatus.RUNNING, JobStatus.SUCCESS, JobStatus.CANCELLED},
|
||
JobStatus.RUNNING: {JobStatus.SUCCESS, JobStatus.FAILED, JobStatus.CANCELLED},
|
||
JobStatus.FAILED: {JobStatus.PENDING}, # 重试回到 pending
|
||
}
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class Job:
|
||
"""统一异步任务实体。
|
||
|
||
Attributes:
|
||
id: 任务唯一标识
|
||
project_id: 所属项目
|
||
job_type: 任务类型
|
||
status: 当前状态
|
||
progress: 进度百分比 (0.0 ~ 100.0)
|
||
current_stage: 当前阶段描述(人类可读)
|
||
payload: 任务输入参数(JSON 序列化)
|
||
result: 任务结果(JSON 序列化)
|
||
error_message: 错误信息
|
||
retry_count: 已重试次数
|
||
max_retries: 最大重试次数
|
||
celery_task_id: Celery 异步任务 ID
|
||
source_id: 关联的业务实体 ID(如 edit_plan_id, generation_task_id)
|
||
created_by_user_id: 创建人
|
||
started_at: 开始执行时间
|
||
completed_at: 完成时间
|
||
created_at: 创建时间
|
||
updated_at: 最后更新时间
|
||
"""
|
||
|
||
id: str
|
||
project_id: str
|
||
job_type: JobType
|
||
status: JobStatus = JobStatus.PENDING
|
||
progress: float = 0.0
|
||
current_stage: str = ""
|
||
payload: dict = field(default_factory=dict)
|
||
result: dict = field(default_factory=dict)
|
||
error_message: str = ""
|
||
retry_count: int = 0
|
||
max_retries: int = 3
|
||
celery_task_id: str = ""
|
||
source_id: str = ""
|
||
created_by_user_id: str = ""
|
||
started_at: datetime | None = None
|
||
completed_at: datetime | None = None
|
||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||
|
||
@classmethod
|
||
def create(
|
||
cls,
|
||
project_id: str,
|
||
job_type: JobType | str,
|
||
*,
|
||
payload: dict | None = None,
|
||
source_id: str = "",
|
||
created_by_user_id: str = "",
|
||
max_retries: int = 3,
|
||
) -> Job:
|
||
"""创建新任务。
|
||
|
||
Args:
|
||
project_id: 项目 ID
|
||
job_type: 任务类型
|
||
payload: 任务输入参数
|
||
source_id: 关联业务实体 ID
|
||
created_by_user_id: 创建人 ID
|
||
max_retries: 最大重试次数
|
||
|
||
Returns:
|
||
新建的 Job 实例
|
||
|
||
Raises:
|
||
ValueError: 参数校验失败
|
||
"""
|
||
if not project_id.strip():
|
||
raise ValueError("project_id 不能为空")
|
||
|
||
# 兼容字符串类型
|
||
if isinstance(job_type, str):
|
||
try:
|
||
job_type = JobType(job_type)
|
||
except ValueError:
|
||
raise ValueError(f"不支持的任务类型: {job_type}")
|
||
|
||
return cls(
|
||
id=uuid4().hex,
|
||
project_id=project_id.strip(),
|
||
job_type=job_type,
|
||
payload=payload or {},
|
||
source_id=source_id.strip(),
|
||
created_by_user_id=created_by_user_id.strip(),
|
||
max_retries=max_retries,
|
||
)
|
||
|
||
@property
|
||
def is_terminal(self) -> bool:
|
||
"""是否处于终态。"""
|
||
return self.status in TERMINAL_STATUSES
|
||
|
||
@property
|
||
def is_retryable(self) -> bool:
|
||
"""是否可重试(失败且未超过重试上限)。"""
|
||
return self.status == JobStatus.FAILED and self.retry_count < self.max_retries
|
||
|
||
def transition_to(self, new_status: JobStatus | str) -> None:
|
||
"""执行状态转换。
|
||
|
||
Args:
|
||
new_status: 目标状态
|
||
|
||
Raises:
|
||
ValueError: 非法状态转换
|
||
"""
|
||
if isinstance(new_status, str):
|
||
try:
|
||
new_status = JobStatus(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(s.value for s in allowed)}}}"
|
||
)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
self.status = new_status
|
||
self.updated_at = now
|
||
|
||
# 自动设置时间戳
|
||
if new_status == JobStatus.RUNNING and self.started_at is None:
|
||
self.started_at = now
|
||
elif new_status in (JobStatus.SUCCESS, JobStatus.FAILED):
|
||
self.completed_at = now
|
||
|
||
def mark_running(self, stage: str = "") -> None:
|
||
"""标记为执行中。"""
|
||
self.transition_to(JobStatus.RUNNING)
|
||
if stage:
|
||
self.current_stage = stage
|
||
|
||
def mark_success(self, result: dict | None = None) -> None:
|
||
"""标记为成功。"""
|
||
self.transition_to(JobStatus.SUCCESS)
|
||
self.progress = 100.0
|
||
self.current_stage = "完成"
|
||
if result:
|
||
self.result = result
|
||
|
||
def mark_failed(self, error_message: str) -> None:
|
||
"""标记为失败。"""
|
||
self.transition_to(JobStatus.FAILED)
|
||
self.error_message = error_message
|
||
self.current_stage = "失败"
|
||
|
||
def mark_cancelled(self) -> None:
|
||
"""标记为取消。"""
|
||
self.transition_to(JobStatus.CANCELLED)
|
||
self.current_stage = "已取消"
|
||
|
||
def update_progress(self, progress: float, stage: str = "") -> None:
|
||
"""更新进度。
|
||
|
||
Args:
|
||
progress: 进度百分比 (0.0 ~ 100.0)
|
||
stage: 当前阶段描述
|
||
|
||
Raises:
|
||
ValueError: 进度值非法
|
||
"""
|
||
if not 0.0 <= progress <= 100.0:
|
||
raise ValueError(f"进度必须在 0~100 之间,当前: {progress}")
|
||
self.progress = progress
|
||
if stage:
|
||
self.current_stage = stage
|
||
self.updated_at = datetime.now(timezone.utc)
|
||
|
||
def prepare_retry(self) -> None:
|
||
"""准备重试:重置状态为 pending。
|
||
|
||
Raises:
|
||
ValueError: 不可重试
|
||
"""
|
||
if not self.is_retryable:
|
||
raise ValueError(
|
||
f"任务不可重试: status={self.status.value}, "
|
||
f"retry_count={self.retry_count}, max_retries={self.max_retries}"
|
||
)
|
||
self.retry_count += 1
|
||
self.transition_to(JobStatus.PENDING)
|
||
self.progress = 0.0
|
||
self.current_stage = f"第 {self.retry_count} 次重试"
|
||
self.error_message = ""
|
||
self.started_at = None
|
||
self.completed_at = None
|
||
self.celery_task_id = ""
|
||
|
||
def to_dict(self) -> dict:
|
||
"""序列化为字典。"""
|
||
return {
|
||
"id": self.id,
|
||
"project_id": self.project_id,
|
||
"job_type": self.job_type.value,
|
||
"status": self.status.value,
|
||
"progress": self.progress,
|
||
"current_stage": self.current_stage,
|
||
"payload": self.payload,
|
||
"result": self.result,
|
||
"error_message": self.error_message,
|
||
"retry_count": self.retry_count,
|
||
"max_retries": self.max_retries,
|
||
"celery_task_id": self.celery_task_id,
|
||
"source_id": self.source_id,
|
||
"created_by_user_id": self.created_by_user_id,
|
||
"is_retryable": self.is_retryable,
|
||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||
}
|