Files
xiaoxia-saas/packages/application/jobs.py
T
CI Bot 9c6c477f55
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 2m22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m24s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 37s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 4m3s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
fix(backend): Phase 1 后端代码清理与修复
P0 关键修复:
- P0-1: 注册接口添加 RateLimitMiddleware 限流保护
- P0-3: /metrics 端点添加 JWT 认证(移除匿名访问)
- P0-4: 修复 Celery 任务名冲突(generation_task vs generate_video)
- P1-5: JWT logout token 黑名单机制

P1 修复:
- P1-1: forgot_password 硬编码 localhost → 使用 settings.APP_BASE_URL
- P1-2: generation.py 直接创建 DB 连接 → 使用依赖注入
- P1-6: Image.open() 未关闭 → 统一使用 with 语句
- P1-7: 订阅续费事务修复

P2 代码质量:
- P2-1: 修复 EditingMode 枚举重复定义 → 统一引用 shared 包
- P2-2: 修复 SMTP_FRON_NAME → SMTP_FROM_NAME 拼写
- P2-3: UserModel subscription_quota 类型统一为 float
- P2-4: .env.production DATABASE_MAX_OVERFLOW 30 → 10
- 清理 15 处 except:pass(保留 2 处有注释说明的)
- 禁用 SVG 上传(XSS 风险)
- 删除 decode_token_unsafe() 不安全函数
- 简化 /ready 端点
- 删除 8 处死代码、10 个空文件/模块
- 合并 3 对 100% 重复函数
- 对齐 6 个废弃环境变量

v2 修复(代码审查后):
- 修复密码重置路由路径: /password/forgot → /forgot-password,
  /password/reset → /reset-password(与前端 API 对齐)
- 合并 _check_project_access: asset_libraries.py 和 edit_plans.py
  中的重复函数统一到 _helpers.py(含空字符串守卫 + 中文错误信息)
- 顺手修复: HTTPException 统一从 fastapi 导入(替换 starlette 导入)
- OSS_ENDPOINT 拼写修复拆分为单独 PR,本 PR 不包含
2026-07-13 13:50:52 +08:00

264 lines
8.1 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""JobService 应用层用例 — Phase 8 任务 2.10.
提供任务创建、提交、状态管理、进度追踪、失败重试、查询等核心能力。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from packages.domain.job import Job, JobStatus, JobType
from packages.ports.job_repository import JobRepository
logger = logging.getLogger(__name__)
# ── 命令对象 ──────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class CreateJobCommand:
"""创建任务的命令。"""
project_id: str
job_type: JobType | str
payload: dict = field(default_factory=dict)
source_id: str = ""
created_by_user_id: str = ""
max_retries: int = 3
@dataclass(slots=True)
class UpdateJobProgressCommand:
"""更新任务进度的命令。"""
job_id: str
progress: float
current_stage: str = ""
@dataclass(slots=True)
class CompleteJobCommand:
"""完成任务的命令。"""
job_id: str
result: dict = field(default_factory=dict)
@dataclass(slots=True)
class FailJobCommand:
"""标记任务失败的命令。"""
job_id: str
error_message: str
# ── 用例 ─────────────────────────────────────────────────────────────────────
class CreateJobUseCase:
"""创建异步任务。
创建后状态为 pending,不自动提交到 Celery(由调用方决定何时 dispatch)。
"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
def execute(self, command: CreateJobCommand) -> Job:
job = Job.create(
project_id=command.project_id,
job_type=command.job_type,
payload=command.payload,
source_id=command.source_id,
created_by_user_id=command.created_by_user_id,
max_retries=command.max_retries,
)
logger.info(
"创建任务: job_id=%s type=%s project=%s",
job.id,
job.job_type.value,
job.project_id,
)
return self._job_repo.create(job)
class SubmitJobUseCase:
"""提交任务执行:将任务状态切换为 running 并返回 Celery 任务 ID 占位。
实际的 Celery dispatch 由上层(路由层 / 服务层)完成,本用例仅做状态流转。
"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
def execute(self, job_id: str, celery_task_id: str = "") -> Job:
job = self._job_repo.get(job_id)
if job is None:
raise ValueError(f"任务不存在: {job_id}")
if job.status != JobStatus.PENDING:
raise ValueError(f"只有 pending 状态的任务可以提交,当前状态: {job.status.value}")
job.mark_running(stage="已提交,等待执行")
job.celery_task_id = celery_task_id
return self._job_repo.update(job)
class UpdateJobProgressUseCase:
"""更新任务进度。"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
def execute(self, command: UpdateJobProgressCommand) -> Job:
job = self._job_repo.get(command.job_id)
if job is None:
raise ValueError(f"任务不存在: {command.job_id}")
if job.status != JobStatus.RUNNING:
raise ValueError(f"只有 running 状态的任务可以更新进度,当前状态: {job.status.value}")
job.update_progress(command.progress, command.current_stage)
return self._job_repo.update(job)
class CompleteJobUseCase:
"""标记任务成功完成。"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
def execute(self, command: CompleteJobCommand) -> Job:
job = self._job_repo.get(command.job_id)
if job is None:
raise ValueError(f"任务不存在: {command.job_id}")
if job.status not in (JobStatus.RUNNING, JobStatus.PENDING):
raise ValueError(f"只有 running/pending 状态的任务可以标记完成,当前状态: {job.status.value}")
job.mark_success(result=command.result)
logger.info("任务完成: job_id=%s", job.id)
return self._job_repo.update(job)
class FailJobUseCase:
"""标记任务失败。"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
def execute(self, command: FailJobCommand) -> Job:
job = self._job_repo.get(command.job_id)
if job is None:
raise ValueError(f"任务不存在: {command.job_id}")
job.mark_failed(command.error_message)
logger.warning("任务失败: job_id=%s error=%s", job.id, command.error_message)
return self._job_repo.update(job)
class RetryJobUseCase:
"""重试失败任务。
将 failed 任务重置为 pendingretry_count + 1。
实际的 Celery 重新 dispatch 由调用方完成。
"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
def execute(self, job_id: str) -> Job:
job = self._job_repo.get(job_id)
if job is None:
raise ValueError(f"任务不存在: {job_id}")
job.prepare_retry()
logger.info("任务重试: job_id=%s retry_count=%d", job.id, job.retry_count)
return self._job_repo.update(job)
class CancelJobUseCase:
"""取消任务。"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
def execute(self, job_id: str) -> Job:
job = self._job_repo.get(job_id)
if job is None:
raise ValueError(f"任务不存在: {job_id}")
if job.is_terminal:
raise ValueError(f"任务已处于终态,无法取消: {job.status.value}")
job.mark_cancelled()
logger.info("任务取消: job_id=%s", job.id)
return self._job_repo.update(job)
class GetJobUseCase:
"""获取单个任务详情。"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
def execute(self, job_id: str) -> Job | None:
return self._job_repo.get(job_id)
class ListJobsUseCase:
"""列出任务(支持按项目 / 用户 / 类型 / 状态过滤)。"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
def execute(
self,
*,
project_id: str | None = None,
user_id: str | None = None,
job_type: JobType | str | None = None,
status: JobStatus | str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Job]:
if project_id:
return self._job_repo.list_by_project(
project_id,
job_type=job_type,
status=status,
limit=limit,
offset=offset,
)
if user_id:
return self._job_repo.list_by_user(
user_id,
job_type=job_type,
status=status,
limit=limit,
offset=offset,
)
raise ValueError("必须指定 project_id 或 user_id")
class GetJobStatisticsUseCase:
"""获取项目的任务统计摘要。"""
def __init__(self, job_repo: JobRepository):
self._job_repo = job_repo
def execute(self, project_id: str) -> dict[str, Any]:
total = self._job_repo.count_by_project(project_id)
pending = self._job_repo.count_by_project(project_id, status=JobStatus.PENDING)
running = self._job_repo.count_by_project(project_id, status=JobStatus.RUNNING)
success = self._job_repo.count_by_project(project_id, status=JobStatus.SUCCESS)
failed = self._job_repo.count_by_project(project_id, status=JobStatus.FAILED)
return {
"project_id": project_id,
"total": total,
"pending": pending,
"running": running,
"success": success,
"failed": failed,
}