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.
137 lines
4.7 KiB
Python
Executable File
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
|