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
+41
View File
@@ -0,0 +1,41 @@
# `packages/task/` — 统一任务中心
**全系统唯一的长耗时任务视图**(架构 §6.2、ADR-14)。报告、换装、发型、抠图、
渲染全部走这里,前端任务中心直接读 `tk_task`。
## 铁律
**不要在业务域里自建任务状态字段。** 每个功能各自实现一遍任务状态、进度、
幂等、重试,集成时会全部返工 —— 这正是 M1 存在的理由。
## 已实现
- `tk_task` 表与状态机(`packages/domain/task/state.py`,纯逻辑可单测)
- 幂等提交(相同 `idempotency_key` 返回同一任务,不重复扣点,FR-TASK-05)
- 归属校验(架构 §11.1)
- Celery 四队列(gpu/cpu/io/beat)与一个贯通的 `ping` 任务
## 待实现
| 需求 | 内容 |
| --- | --- |
| FR-TASK-02 | 进度与预估时间(阶段文案 + 排队位次 + 基于历史 p50 的预估) |
| FR-TASK-06 | 任务取消:排队中可取消并退点;执行中标记取消不再返回结果 |
| FR-TASK-07 | 死信队列与告警:连续失败进死信并告警,**不静默丢弃** |
| — | beat 防重(单实例);自动重试与上限 |
## 状态机
```
pending → queued → running → succeeded
↓ ↓ ↓ ↘ failed → queued(重试)
failed failed canceled canceled
```
终态是 `succeeded` / `failed` / `canceled`;只有 `failed` 可以重新入队。
非成功终态**必须触发配额退还**(`needs_refund()`)。
## 排队时间与执行时间必须分开统计
架构 §12.1:混在一起看会得出错误结论 —— 明明是 GPU 容量不足(该扩容),
却被误判为推理慢(去优化模型)。
+1
View File
@@ -0,0 +1 @@
"""task 域:统一任务中心。"""
+37
View File
@@ -0,0 +1,37 @@
"""Data access for the task domain. **Only this module may touch tk_task.**"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from packages.shared.models import Task
async def get(session: AsyncSession, task_id: int) -> Task | None:
return await session.get(Task, task_id)
async def get_for_update(session: AsyncSession, task_id: int) -> Task | None:
result = await session.execute(select(Task).where(Task.task_id == task_id).with_for_update())
return result.scalar_one_or_none()
async def find_by_idempotency_key(session: AsyncSession, key: str) -> Task | None:
result = await session.execute(select(Task).where(Task.idempotency_key == key))
return result.scalar_one_or_none()
async def list_for_user(session: AsyncSession, user_id: int, limit: int = 20) -> list[Task]:
result = await session.execute(
select(Task)
.where(Task.user_id == user_id)
.order_by(Task.submitted_at.desc(), Task.task_id.desc())
.limit(limit)
)
return list(result.scalars().all())
def add(session: AsyncSession, task: Task) -> Task:
session.add(task)
return task
+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