The product plan is the only source for quota numbers, but the 0001 seed used a second set (agent_chat 20/50, tryon hard 10) and omitted hairstyle, hd_export and report regen. Migration 0003 replaces the seed and renames points_reserved → quota_reserved and bonus_value → bonus_granted so the docs and the schema stop contradicting each other. Effective limit now clips at hard_cap. CI runs the integration slice against the Postgres/Redis services it already starts.
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,
|
|
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
|