feat(shared): cross-cutting plumbing and pure domain layer
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.
This commit is contained in:
Executable
+39
@@ -0,0 +1,39 @@
|
||||
# `packages/domain/` — 纯领域逻辑
|
||||
|
||||
架构 §3.2 硬性规则 2:**领域逻辑放在这里,不依赖 FastAPI、不依赖数据库会话,
|
||||
可纯单元测试。**
|
||||
|
||||
## 能放什么
|
||||
|
||||
- 数据类与值对象(配额状态、任务状态、诊断维度…)
|
||||
- 判定与计算(配额是否够、状态能否流转、置信度冲突消解、色差计算…)
|
||||
- 纯函数、不可变数据结构
|
||||
|
||||
## 绝对不能放什么
|
||||
|
||||
- `import fastapi` / `sqlalchemy` / `httpx` / `redis` / `celery`
|
||||
- `import packages.shared`(连日志和配置都不行)
|
||||
- 任何 IO:读文件、发请求、查库、读环境变量
|
||||
- `async def` 里真的 await 了 IO 的东西
|
||||
|
||||
违反会被 import-linter 的 `packages.domain is pure` 契约在 CI 拦下。
|
||||
|
||||
## 为什么这么严
|
||||
|
||||
这是 AI 大量写代码时唯一还能快速验证正确性的地方 —— 没有 mock、没有
|
||||
fixture、没有容器,几毫秒跑完几百个用例。一旦让 IO 渗进来,这个性质就没了,
|
||||
后面所有业务规则的测试都会退化成集成测试。
|
||||
|
||||
## mypy strict
|
||||
|
||||
本目录与 `packages/llm/` 一起,是 mypy **strict** 模式覆盖的范围
|
||||
(架构 §16.2)。所有函数必须有完整类型注解。
|
||||
|
||||
## 现有内容
|
||||
|
||||
| 模块 | 内容 |
|
||||
| --- | --- |
|
||||
| `ids.py` | 雪花 ID 生成(§6.1 原则 2) |
|
||||
| `errors.py` | 纯领域异常,适配层负责翻译成 `AppError` |
|
||||
| `billing/quota.py` | 配额三段式的算术与周期键(§7.4) |
|
||||
| `task/state.py` | `tk_task` 状态机(§6.2) |
|
||||
@@ -0,0 +1 @@
|
||||
"""纯领域逻辑:无 IO、无框架、可纯单测(架构文档 §3.2)。"""
|
||||
@@ -0,0 +1 @@
|
||||
"""配额与账本的纯逻辑(架构文档 §7.4)。"""
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
"""Quota arithmetic — pure logic, no IO (架构文档 §7.4 V1 配额三段式).
|
||||
|
||||
The three-phase protocol lives here as data transformations so it can be unit
|
||||
tested without a database:
|
||||
|
||||
reserve → 冻结额度并写 bl_ledger(status=reserved)
|
||||
commit → 确认扣除 (status=committed)
|
||||
refund → 释放额度并写反向流水 (tx_type=refund)
|
||||
|
||||
V1 doesn't involve money, but 用户对「次数被白扣」的敏感度不比扣钱低,and V1
|
||||
has no customer-service refund path — the mechanism is the only guarantee.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from packages.domain.errors import QuotaExceededError
|
||||
|
||||
PeriodType = Literal["day", "month", "lifetime"]
|
||||
LedgerStatus = Literal["reserved", "committed", "refunded"]
|
||||
TxType = Literal["system_grant", "consume", "refund", "purchase", "gift"]
|
||||
|
||||
#: FR-BILL-01: 跨时区按 UTC+8 统一。
|
||||
DEFAULT_TZ_OFFSET_HOURS = 8
|
||||
|
||||
|
||||
def period_key(
|
||||
now: datetime,
|
||||
period_type: PeriodType,
|
||||
tz_offset_hours: int = DEFAULT_TZ_OFFSET_HOURS,
|
||||
) -> str:
|
||||
"""Bucket key that makes quota reset naturally, with no cron job.
|
||||
|
||||
``day`` → ``d:2026-08-13``; ``month`` → ``m:2026-08``; ``lifetime`` → ``life``.
|
||||
"""
|
||||
if period_type == "lifetime":
|
||||
return "life"
|
||||
local = now.astimezone(UTC) + timedelta(hours=tz_offset_hours)
|
||||
if period_type == "day":
|
||||
return f"d:{local:%Y-%m-%d}"
|
||||
if period_type == "month":
|
||||
return f"m:{local:%Y-%m}"
|
||||
msg = f"unknown period_type: {period_type}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def period_reset_at(
|
||||
now: datetime,
|
||||
period_type: PeriodType,
|
||||
tz_offset_hours: int = DEFAULT_TZ_OFFSET_HOURS,
|
||||
) -> datetime | None:
|
||||
"""When the current period rolls over (used for the `reset_at` hint)."""
|
||||
if period_type == "lifetime":
|
||||
return None
|
||||
tz = timezone(timedelta(hours=tz_offset_hours))
|
||||
local = now.astimezone(tz)
|
||||
if period_type == "day":
|
||||
nxt = (local + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
else:
|
||||
year, month = (local.year + 1, 1) if local.month == 12 else (local.year, local.month + 1)
|
||||
nxt = local.replace(
|
||||
year=year, month=month, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
return nxt.astimezone(UTC)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QuotaState:
|
||||
"""Current period counters for one (user, quota_key)."""
|
||||
|
||||
used: int
|
||||
base_limit: int
|
||||
bonus: int = 0
|
||||
|
||||
@property
|
||||
def effective_limit(self) -> int:
|
||||
return self.base_limit + self.bonus
|
||||
|
||||
@property
|
||||
def remaining(self) -> int:
|
||||
return max(self.effective_limit - self.used, 0)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QuotaDecision:
|
||||
allowed: bool
|
||||
required: int
|
||||
remaining: int
|
||||
effective_limit: int
|
||||
|
||||
|
||||
def evaluate(state: QuotaState, amount: int) -> QuotaDecision:
|
||||
"""Server-side hard check (FR-BILL-06). Client hints are UX only."""
|
||||
if amount <= 0:
|
||||
msg = "amount must be positive"
|
||||
raise ValueError(msg)
|
||||
return QuotaDecision(
|
||||
allowed=state.used + amount <= state.effective_limit,
|
||||
required=amount,
|
||||
remaining=state.remaining,
|
||||
effective_limit=state.effective_limit,
|
||||
)
|
||||
|
||||
|
||||
def reserve(state: QuotaState, amount: int) -> QuotaState:
|
||||
"""Freeze ``amount`` units; raises :class:`QuotaExceededError` when over the limit."""
|
||||
decision = evaluate(state, amount)
|
||||
if not decision.allowed:
|
||||
raise QuotaExceededError(required=amount, remaining=decision.remaining)
|
||||
return replace(state, used=state.used + amount)
|
||||
|
||||
|
||||
def refund(state: QuotaState, amount: int) -> QuotaState:
|
||||
"""Release a previously reserved amount. Never drives ``used`` below zero."""
|
||||
if amount <= 0:
|
||||
msg = "amount must be positive"
|
||||
raise ValueError(msg)
|
||||
return replace(state, used=max(state.used - amount, 0))
|
||||
|
||||
|
||||
def balance_after(state: QuotaState) -> int:
|
||||
"""Value written to ``bl_ledger.balance_after``.
|
||||
|
||||
V1 has no wallet balance, so the ledger records *remaining quota* — that is
|
||||
the number the user actually sees ("今日剩余 N 次"). When V2 introduces
|
||||
``bl_wallet`` this becomes the wallet balance without a schema change.
|
||||
"""
|
||||
return state.remaining
|
||||
|
||||
|
||||
def reserve_idempotency_key(*, user_id: int, quota_key: str, request_key: str) -> str:
|
||||
return f"rsv:{user_id}:{quota_key}:{request_key}"
|
||||
|
||||
|
||||
def refund_idempotency_key(reserve_key: str) -> str:
|
||||
"""FR-BILL-05: 退还必须幂等。Derived, so a retry cannot double-refund."""
|
||||
return f"rfd:{reserve_key}"
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
"""Pure-domain exceptions.
|
||||
|
||||
This layer must not import :mod:`packages.shared` (enforced by import-linter),
|
||||
so it defines its own error types. Adapters translate them into
|
||||
:class:`packages.shared.errors.AppError` at the boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class DomainError(Exception):
|
||||
"""Base class for all pure-domain rule violations."""
|
||||
|
||||
|
||||
class QuotaExceededError(DomainError):
|
||||
def __init__(self, *, required: int, remaining: int) -> None:
|
||||
self.required = required
|
||||
self.remaining = remaining
|
||||
super().__init__(f"quota exceeded: required={required} remaining={remaining}")
|
||||
|
||||
|
||||
class InvalidTaskTransitionError(DomainError):
|
||||
def __init__(self, *, current: str, target: str) -> None:
|
||||
self.current = current
|
||||
self.target = target
|
||||
super().__init__(f"invalid task transition: {current} -> {target}")
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
"""Snowflake IDs (架构文档 §6.1 原则 2).
|
||||
|
||||
Auto-increment keys invite enumeration attacks and collide when the database is
|
||||
later sharded; UUID primary keys have poor index locality. A 64-bit snowflake
|
||||
avoids both.
|
||||
|
||||
Layout: 41 bits ms since epoch | 10 bits node id | 12 bits sequence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
#: 2025-01-01T00:00:00Z in milliseconds. Do not change once IDs are issued.
|
||||
DEFAULT_EPOCH_MS = 1_735_689_600_000
|
||||
|
||||
_NODE_BITS = 10
|
||||
_SEQUENCE_BITS = 12
|
||||
_MAX_NODE_ID = (1 << _NODE_BITS) - 1
|
||||
_SEQUENCE_MASK = (1 << _SEQUENCE_BITS) - 1
|
||||
|
||||
|
||||
def _wall_clock_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
class SnowflakeGenerator:
|
||||
"""Thread-safe monotonic 64-bit id generator."""
|
||||
|
||||
__slots__ = ("_clock", "_epoch_ms", "_last_ms", "_lock", "_node_id", "_sequence")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node_id: int = 1,
|
||||
epoch_ms: int = DEFAULT_EPOCH_MS,
|
||||
clock: Callable[[], int] | None = None,
|
||||
) -> None:
|
||||
if not 0 <= node_id <= _MAX_NODE_ID:
|
||||
msg = f"node_id must be within 0..{_MAX_NODE_ID}"
|
||||
raise ValueError(msg)
|
||||
self._node_id = node_id
|
||||
self._epoch_ms = epoch_ms
|
||||
self._sequence = 0
|
||||
self._last_ms = -1
|
||||
self._lock = threading.Lock()
|
||||
self._clock = clock or _wall_clock_ms
|
||||
|
||||
def _now_ms(self) -> int:
|
||||
return self._clock()
|
||||
|
||||
def next_id(self) -> int:
|
||||
with self._lock:
|
||||
now = self._now_ms()
|
||||
if now < self._last_ms:
|
||||
# Clock moved backwards: wait it out rather than issue duplicates.
|
||||
now = self._last_ms
|
||||
if now == self._last_ms:
|
||||
self._sequence = (self._sequence + 1) & _SEQUENCE_MASK
|
||||
if self._sequence == 0:
|
||||
while now <= self._last_ms:
|
||||
now = self._now_ms()
|
||||
else:
|
||||
self._sequence = 0
|
||||
self._last_ms = now
|
||||
elapsed = now - self._epoch_ms
|
||||
return (
|
||||
(elapsed << (_NODE_BITS + _SEQUENCE_BITS))
|
||||
| (self._node_id << _SEQUENCE_BITS)
|
||||
| self._sequence
|
||||
)
|
||||
|
||||
|
||||
_default_generator = SnowflakeGenerator()
|
||||
|
||||
|
||||
def next_id() -> int:
|
||||
"""Process-wide default generator."""
|
||||
return _default_generator.next_id()
|
||||
|
||||
|
||||
def timestamp_ms_of(snowflake_id: int, epoch_ms: int = DEFAULT_EPOCH_MS) -> int:
|
||||
"""Extract the creation timestamp from an id (useful for partition pruning)."""
|
||||
return (snowflake_id >> (_NODE_BITS + _SEQUENCE_BITS)) + epoch_ms
|
||||
@@ -0,0 +1 @@
|
||||
"""统一任务中心的状态机(架构文档 §6.2)。"""
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user