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
+267
View File
@@ -0,0 +1,267 @@
"""Quota three-phase protocol: reserve → commit → refund (架构文档 §7.4).
为什么预扣而不是直接扣: 长耗时任务可能失败,直接扣会产生「扣了额度没拿到
结果」的客诉。V1 没有客服退款兜底手段,只能靠机制保证 (FR-BILL-05).
Nothing here decides *how much* a feature costs — that comes from
``bl_quota_rule`` so it can change without a release (FR-BILL-04).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import cast
from sqlalchemy.ext.asyncio import AsyncSession
from packages.billing import repo
from packages.domain.billing import quota as quota_rules
from packages.domain.errors import QuotaExceededError
from packages.domain.ids import next_id
from packages.shared.errors import AppError, ErrorCode, QuotaExhaustedError
from packages.shared.logging import get_logger
from packages.shared.models import Ledger, QuotaRule
from packages.shared.observability import QUOTA_EVENTS
logger = get_logger(__name__)
@dataclass(frozen=True, slots=True)
class Reservation:
tx_id: int
user_id: int
quota_key: str
period_key: str
amount: int
remaining: int
idempotency_key: str
@dataclass(frozen=True, slots=True)
class QuotaSnapshot:
quota_key: str
period_key: str
used: int
limit_value: int
bonus_value: int
remaining: int
reset_at: datetime | None
class BillingService:
def __init__(self, session: AsyncSession, *, tz_offset_hours: int = 8) -> None:
self._session = session
self._tz_offset = tz_offset_hours
# -- read ---------------------------------------------------------------
async def get_quota(self, *, user_id: int, quota_key: str) -> QuotaSnapshot:
rule = await self._require_rule(quota_key)
period_type = cast(quota_rules.PeriodType, rule.period_type)
now = datetime.now(UTC)
period_key = quota_rules.period_key(now, period_type, self._tz_offset)
row = await repo.lock_or_create_quota(
self._session,
user_id=user_id,
quota_key=quota_key,
period_key=period_key,
limit_value=rule.base_limit,
)
state = quota_rules.QuotaState(
used=row.used, base_limit=row.limit_value, bonus=row.bonus_value
)
return QuotaSnapshot(
quota_key=quota_key,
period_key=period_key,
used=row.used,
limit_value=row.limit_value,
bonus_value=row.bonus_value,
remaining=state.remaining,
reset_at=quota_rules.period_reset_at(now, period_type, self._tz_offset),
)
async def list_ledger(self, *, user_id: int, limit: int = 50) -> list[Ledger]:
return await repo.list_ledger(self._session, user_id, limit)
# -- phase 1: reserve ---------------------------------------------------
async def reserve(
self,
*,
user_id: int,
quota_key: str,
request_key: str,
amount: int = 1,
reason: str | None = None,
ref_type: str | None = None,
ref_id: str | None = None,
) -> Reservation:
"""Freeze ``amount`` units. Idempotent on ``request_key``."""
idem_key = quota_rules.reserve_idempotency_key(
user_id=user_id, quota_key=quota_key, request_key=request_key
)
existing = await repo.get_ledger_by_idempotency_key(self._session, idem_key)
if existing is not None:
QUOTA_EVENTS.labels(quota_key, "reserve", "idempotent_hit").inc()
return Reservation(
tx_id=existing.tx_id,
user_id=user_id,
quota_key=quota_key,
period_key=existing.period_key or "",
amount=abs(existing.delta),
remaining=existing.balance_after,
idempotency_key=idem_key,
)
rule = await self._require_rule(quota_key)
period_type = cast(quota_rules.PeriodType, rule.period_type)
now = datetime.now(UTC)
period_key = quota_rules.period_key(now, period_type, self._tz_offset)
row = await repo.lock_or_create_quota(
self._session,
user_id=user_id,
quota_key=quota_key,
period_key=period_key,
limit_value=rule.base_limit,
)
state = quota_rules.QuotaState(
used=row.used, base_limit=row.limit_value, bonus=row.bonus_value
)
try:
new_state = quota_rules.reserve(state, amount)
except QuotaExceededError as exc:
QUOTA_EVENTS.labels(quota_key, "reserve", "exhausted").inc()
reset_at = quota_rules.period_reset_at(now, period_type, self._tz_offset)
raise QuotaExhaustedError(
required=exc.required,
remaining=exc.remaining,
reset_at=reset_at.isoformat() if reset_at else None,
) from exc
row.used = new_state.used
tx_id = next_id()
repo.add_ledger(
self._session,
Ledger(
tx_id=tx_id,
user_id=user_id,
delta=-amount,
tx_type="consume",
status="reserved",
reason=reason,
ref_type=ref_type,
ref_id=ref_id,
quota_key=quota_key,
period_key=period_key,
balance_after=quota_rules.balance_after(new_state),
idempotency_key=idem_key,
),
)
await self._session.flush()
QUOTA_EVENTS.labels(quota_key, "reserve", "ok").inc()
logger.info(
"quota_reserved",
tx_id=tx_id,
quota_key=quota_key,
amount=amount,
remaining=new_state.remaining,
)
return Reservation(
tx_id=tx_id,
user_id=user_id,
quota_key=quota_key,
period_key=period_key,
amount=amount,
remaining=new_state.remaining,
idempotency_key=idem_key,
)
# -- phase 2: commit ----------------------------------------------------
async def commit(self, reservation: Reservation) -> None:
"""Confirm the deduction. Safe to call twice."""
ledger = await repo.get_ledger(self._session, reservation.tx_id)
if ledger is None:
raise AppError(ErrorCode.STATE_CONFLICT, message="配额流水不存在")
if ledger.status == "reserved":
ledger.status = "committed"
await self._session.flush()
QUOTA_EVENTS.labels(reservation.quota_key, "commit", "ok").inc()
logger.info("quota_committed", tx_id=ledger.tx_id)
else:
QUOTA_EVENTS.labels(reservation.quota_key, "commit", "noop").inc()
# -- phase 3: refund ----------------------------------------------------
async def refund(self, reservation: Reservation, *, reason: str = "task_failed") -> None:
"""Release a reservation and write the reverse ledger entry.
FR-BILL-05 requires refunds to be idempotent; the refund ledger key is
derived from the reserve key so a retry can never double-refund.
"""
ledger = await repo.get_ledger(self._session, reservation.tx_id)
if ledger is None:
raise AppError(ErrorCode.STATE_CONFLICT, message="配额流水不存在")
if ledger.status != "reserved":
QUOTA_EVENTS.labels(reservation.quota_key, "refund", "noop").inc()
return
refund_key = quota_rules.refund_idempotency_key(reservation.idempotency_key)
if await repo.get_ledger_by_idempotency_key(self._session, refund_key) is not None:
QUOTA_EVENTS.labels(reservation.quota_key, "refund", "noop").inc()
return
rule = await self._require_rule(reservation.quota_key)
row = await repo.lock_or_create_quota(
self._session,
user_id=reservation.user_id,
quota_key=reservation.quota_key,
period_key=reservation.period_key,
limit_value=rule.base_limit,
)
state = quota_rules.QuotaState(
used=row.used, base_limit=row.limit_value, bonus=row.bonus_value
)
new_state = quota_rules.refund(state, reservation.amount)
row.used = new_state.used
ledger.status = "refunded"
repo.add_ledger(
self._session,
Ledger(
tx_id=next_id(),
user_id=reservation.user_id,
delta=reservation.amount,
tx_type="refund",
status="committed",
reason=reason,
ref_type=ledger.ref_type,
ref_id=ledger.ref_id,
quota_key=reservation.quota_key,
period_key=reservation.period_key,
balance_after=quota_rules.balance_after(new_state),
idempotency_key=refund_key,
),
)
await self._session.flush()
QUOTA_EVENTS.labels(reservation.quota_key, "refund", "ok").inc()
logger.info(
"quota_refunded",
tx_id=ledger.tx_id,
amount=reservation.amount,
remaining=new_state.remaining,
)
# -- helpers ------------------------------------------------------------
async def _require_rule(self, quota_key: str) -> QuotaRule:
rule = await repo.get_rule(self._session, quota_key)
if rule is None:
raise AppError(
ErrorCode.CONFIG_ERROR,
message="配额规则未配置",
data={"quota_key": quota_key},
)
return rule