Files
meida_backend/packages/task/service.py
T
xsl 7c67506474 feat(domains): identity, billing, task services and domain placeholders
Billing implements reserve -> commit -> refund against bl_quota and bl_ledger
with row-level locking and idempotency keys; the refund commits on its own
transaction so a failed request cannot silently keep a user's quota. Task
implements the unified task centre over the state machine. Identity issues and
resolves users. Remaining domains are placeholders whose READMEs state what
belongs there and what must not.
2026-08-14 00:26:39 +08:00

137 lines
4.7 KiB
Python
Executable File

"""统一任务中心 (架构文档 §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,
points_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 {},
points_reserved=points_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