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.
This commit is contained in:
xsl
2026-08-14 00:26:39 +08:00
parent 54dc741811
commit 7c67506474
36 changed files with 1213 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
"""统一任务中心 (架构文档 §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