"""统一任务中心 (架构文档 §6.2 task 域, FR-TASK-01/02/05). **全系统唯一的长耗时任务视图** —— 报告、换装、发型、抠图、渲染都走这里,前端 任务中心直接读 ``tk_task``。State transitions are validated against the pure state machine in :mod:`packages.domain.task.state`, so an illegal transition fails loudly instead of corrupting the task list. """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from packages.domain.errors import InvalidTaskTransitionError from packages.domain.ids import next_id from packages.domain.task.state import ( TaskStatus, TaskType, assert_transition, is_terminal, progress_for, ) from packages.shared.errors import AppError, ErrorCode, ForbiddenError, NotFoundError from packages.shared.logging import get_logger from packages.shared.models import Task from packages.shared.observability import TASK_TRANSITIONS from packages.task import repo logger = get_logger(__name__) class TaskService: def __init__(self, session: AsyncSession) -> None: self._session = session async def submit( self, *, user_id: int, task_type: TaskType, idempotency_key: str, payload: dict[str, Any] | None = None, quota_reserved: int = 0, reserve_tx_id: int | None = None, eta_seconds: int | None = None, ) -> Task: """Create a task. FR-TASK-05: 相同幂等键返回同一任务,不重复扣减配额。""" existing = await repo.find_by_idempotency_key(self._session, idempotency_key) if existing is not None: return existing task = repo.add( self._session, Task( task_id=next_id(), user_id=user_id, task_type=task_type.value, status=TaskStatus.PENDING.value, progress=progress_for(TaskStatus.PENDING), idempotency_key=idempotency_key, payload=payload or {}, quota_reserved=quota_reserved, reserve_tx_id=reserve_tx_id, eta_seconds=eta_seconds, ), ) await self._session.flush() TASK_TRANSITIONS.labels(task_type.value, TaskStatus.PENDING.value).inc() return task async def get(self, *, task_id: int, user_id: int) -> Task: task = await repo.get(self._session, task_id) if task is None: raise NotFoundError("任务不存在") # 架构文档 §11.1: 所有资源访问必须校验归属,不能只靠 ID 不可猜。 if task.user_id != user_id: raise ForbiddenError() return task async def list_for_user(self, *, user_id: int, limit: int = 20) -> list[Task]: return await repo.list_for_user(self._session, user_id, limit) async def transition( self, *, task_id: int, target: TaskStatus, stage_text: str | None = None, progress: int | None = None, result: dict[str, Any] | None = None, error_code: int | None = None, error_message: str | None = None, ) -> Task: task = await repo.get_for_update(self._session, task_id) if task is None: raise NotFoundError("任务不存在") current = TaskStatus(task.status) try: assert_transition(current, target) except InvalidTaskTransitionError as exc: raise AppError( ErrorCode.TASK_STATE_INVALID, data={"current": exc.current, "target": exc.target}, ) from exc now = datetime.now(UTC) task.status = target.value task.progress = progress if progress is not None else progress_for(target) if stage_text is not None: task.stage_text = stage_text if result is not None: task.result = result if error_code is not None: task.error_code = error_code if error_message is not None: task.error_message = error_message[:512] if target is TaskStatus.RUNNING and task.started_at is None: task.started_at = now if target is TaskStatus.QUEUED and current is TaskStatus.FAILED: task.retry_count += 1 if is_terminal(target): task.finished_at = now await self._session.flush() TASK_TRANSITIONS.labels(task.task_type, target.value).inc() logger.info( "task_transition", task_id=task_id, task_type=task.task_type, from_status=current.value, to_status=target.value, ) return task