Typed pydantic-settings config, structlog JSON logging with request/trace id propagation and redaction of phone numbers, tokens and face keys, the unified response envelope and error-code table from 架构文档 §9.2, async SQLAlchemy session management, ORM models for the eight foundation tables, and the OpenTelemetry/Prometheus wiring. packages/domain stays pure logic -- snowflake ids, the quota three-phase protocol and the tk_task state machine -- so it can be tested without a database and is held to mypy strict.
77 lines
2.4 KiB
Python
Executable File
77 lines
2.4 KiB
Python
Executable File
"""tk_task state machine — pure logic (架构文档 §6.2 task 域, FR-TASK-01).
|
|
|
|
统一任务中心 is the only long-running-task view in the system; every long task
|
|
(report / tryon / hairstyle / matting / render) moves through these states.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from enum import StrEnum
|
|
|
|
from packages.domain.errors import InvalidTaskTransitionError
|
|
|
|
|
|
class TaskStatus(StrEnum):
|
|
PENDING = "pending" # created, quota reserved, not yet queued
|
|
QUEUED = "queued" # handed to Celery
|
|
RUNNING = "running"
|
|
SUCCEEDED = "succeeded"
|
|
FAILED = "failed"
|
|
CANCELED = "canceled"
|
|
|
|
|
|
class TaskType(StrEnum):
|
|
REPORT = "report"
|
|
TRYON = "tryon"
|
|
HAIRSTYLE = "hairstyle"
|
|
MATTING = "matting"
|
|
RENDER = "render"
|
|
PING = "ping" # scaffold-only: proves the async path end to end
|
|
|
|
|
|
TERMINAL_STATUSES: frozenset[TaskStatus] = frozenset(
|
|
{TaskStatus.SUCCEEDED, TaskStatus.FAILED, TaskStatus.CANCELED}
|
|
)
|
|
|
|
#: Refunds are owed for every terminal state other than success (FR-TRY-05).
|
|
REFUNDABLE_STATUSES: frozenset[TaskStatus] = frozenset({TaskStatus.FAILED, TaskStatus.CANCELED})
|
|
|
|
ALLOWED_TRANSITIONS: dict[TaskStatus, frozenset[TaskStatus]] = {
|
|
TaskStatus.PENDING: frozenset({TaskStatus.QUEUED, TaskStatus.FAILED, TaskStatus.CANCELED}),
|
|
TaskStatus.QUEUED: frozenset({TaskStatus.RUNNING, TaskStatus.FAILED, TaskStatus.CANCELED}),
|
|
TaskStatus.RUNNING: frozenset(
|
|
{TaskStatus.SUCCEEDED, TaskStatus.FAILED, TaskStatus.CANCELED, TaskStatus.QUEUED}
|
|
),
|
|
TaskStatus.SUCCEEDED: frozenset(),
|
|
TaskStatus.FAILED: frozenset({TaskStatus.QUEUED}), # retry re-queues
|
|
TaskStatus.CANCELED: frozenset(),
|
|
}
|
|
|
|
|
|
def can_transition(current: TaskStatus, target: TaskStatus) -> bool:
|
|
return target in ALLOWED_TRANSITIONS[current]
|
|
|
|
|
|
def assert_transition(current: TaskStatus, target: TaskStatus) -> None:
|
|
if not can_transition(current, target):
|
|
raise InvalidTaskTransitionError(current=current.value, target=target.value)
|
|
|
|
|
|
def is_terminal(status: TaskStatus) -> bool:
|
|
return status in TERMINAL_STATUSES
|
|
|
|
|
|
def needs_refund(status: TaskStatus) -> bool:
|
|
return status in REFUNDABLE_STATUSES
|
|
|
|
|
|
def progress_for(status: TaskStatus) -> int:
|
|
return {
|
|
TaskStatus.PENDING: 0,
|
|
TaskStatus.QUEUED: 5,
|
|
TaskStatus.RUNNING: 50,
|
|
TaskStatus.SUCCEEDED: 100,
|
|
TaskStatus.FAILED: 100,
|
|
TaskStatus.CANCELED: 100,
|
|
}[status]
|