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:
xsl
2026-08-14 00:26:39 +08:00
parent da0314fd08
commit 12583e9739
24 changed files with 1717 additions and 0 deletions
+39
View File
@@ -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) |
+1
View File
@@ -0,0 +1 @@
"""纯领域逻辑:无 IO、无框架、可纯单测(架构文档 §3.2)。"""
+1
View File
@@ -0,0 +1 @@
"""配额与账本的纯逻辑(架构文档 §7.4)。"""
+140
View File
@@ -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}"
+26
View File
@@ -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}")
+85
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""统一任务中心的状态机(架构文档 §6.2)。"""
+76
View File
@@ -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]